diff --git a/crates/ui-egui/src/adjust_dialog.rs b/crates/ui-egui/src/adjust_dialog.rs index 82432879db..3bbf36b3c2 100644 --- a/crates/ui-egui/src/adjust_dialog.rs +++ b/crates/ui-egui/src/adjust_dialog.rs @@ -1075,7 +1075,7 @@ mod tests { #[test] fn curves_channel_popup_keeps_custom_names_verbatim_in_italian() { 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 mut app = app_with_image(); app.run("channel.new", json!({"name": "Green", "fill": "white"})).unwrap(); @@ -1083,13 +1083,13 @@ mod tests { let mut h = curves_harness(app); 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 == "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. let mut colour = app_with_image(); colour.run("channel.target", json!({"channel": {"color": 1}})).unwrap(); let mut h = curves_harness(colour); 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`. diff --git a/crates/ui-egui/src/brush_panel_tests.rs b/crates/ui-egui/src/brush_panel_tests.rs index f0b6cc906b..1ea52bdb72 100644 --- a/crates/ui-egui/src/brush_panel_tests.rs +++ b/crates/ui-egui/src/brush_panel_tests.rs @@ -1378,7 +1378,6 @@ fn brush_sections_paint_simplified_chinese_labels() { for (name, _) in SECTIONS { 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 == name), "{name} must not paint in English"); } } diff --git a/crates/ui-egui/src/delete_layer_prompt.rs b/crates/ui-egui/src/delete_layer_prompt.rs index 7c1913db78..d67d299345 100644 --- a/crates/ui-egui/src/delete_layer_prompt.rs +++ b/crates/ui-egui/src/delete_layer_prompt.rs @@ -154,24 +154,4 @@ mod tests { assert_eq!(clear.shortcut, Some("Delete")); 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); - } - } - } } diff --git a/crates/ui-egui/src/discard_ui.rs b/crates/ui-egui/src/discard_ui.rs index f6f654afa0..263f3652bf 100644 --- a/crates/ui-egui/src/discard_ui.rs +++ b/crates/ui-egui/src/discard_ui.rs @@ -787,8 +787,11 @@ mod tests { assert_eq!(button_labels(false, &buttons), ["(Y)es", "(N)o", "(C)ancel"]); let german = crate::i18n::Lang::from_code("de").unwrap(); crate::i18n::with_language(german, || { - assert_eq!(button_labels(false, &buttons), ["Ja (Y)", "Nein (N)", "Abbrechen (C)"]); - assert_eq!(button_labels(true, &buttons), ["Ja", "Nein", "Abbrechen"]); + let shown: Vec<&str> = buttons.iter().map(|(label, _)| crate::i18n::tr(german, label)).collect(); + assert_eq!(button_labels(true, &buttons), shown); + let labels = button_labels(false, &buttons); + let appended: Vec = 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:?}"); }); } diff --git a/crates/ui-egui/src/doc_props_ui.rs b/crates/ui-egui/src/doc_props_ui.rs index 6a959c38d7..90f70dd77e 100644 --- a/crates/ui-egui/src/doc_props_ui.rs +++ b/crates/ui-egui/src/doc_props_ui.rs @@ -385,15 +385,10 @@ mod tests { } #[test] - fn image_mode_depth_labels_match_sample_types_and_are_translated() { + fn image_mode_depth_labels_match_sample_types() { assert_eq!( DEPTHS.iter().map(|(sample, label, _)| (*sample, *label)).collect::>(), [(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()); - } - } } } diff --git a/crates/ui-egui/src/filter_dialog.rs b/crates/ui-egui/src/filter_dialog.rs index e8c838ebc8..b611b9ac64 100644 --- a/crates/ui-egui/src/filter_dialog.rs +++ b/crates/ui-egui/src/filter_dialog.rs @@ -1396,14 +1396,10 @@ mod tests { 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] fn black_point_compensation_is_spelled_out() { 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 { @@ -1855,105 +1851,14 @@ mod tests { assert!(!has_dialog("filter.stylize.findEdges")); } - /// Generated filter and adjustment labels (param names, symbolic choices) and gallery - /// names that `lang` has no catalog row for. - fn missing_generated_filter_labels(lang: crate::i18n::Lang) -> std::collections::BTreeSet { - 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 - } - + /// Select and Mask's Output To lists all six output targets. #[test] - fn korean_covers_generated_filter_options_and_gallery_names() { - 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>::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() { + fn select_and_mask_output_lists_all_six_targets() { let spec = photocraft_engine::commands::find("select.refineEdge").unwrap().params; 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 labels: Vec = 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"]); - 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] diff --git a/crates/ui-egui/src/gpu_status.rs b/crates/ui-egui/src/gpu_status.rs index d9608a539b..cd2bb7bc4d 100644 --- a/crates/ui-egui/src/gpu_status.rs +++ b/crates/ui-egui/src/gpu_status.rs @@ -297,8 +297,11 @@ mod tests { assert_eq!(result["info"]["lines"], json!(english)); assert_eq!(crate::i18n::current().code(), "cs", "diagnostics restore the drawing language"); let screen = system_info_ui(&app); - assert!(screen.iter().any(|line| line == "Grafický adaptér: Test GPU"), "{screen:?}"); - assert!(screen.iter().any(|line| line.starts_with("Profil monitoru:")), "{screen:?}"); + let cs = crate::i18n::current(); + 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")); }); } @@ -313,8 +316,11 @@ mod tests { app.perf.gpu_info.preference = "auto".into(); app.perf.gpu_info.selected = "auto".into(); assert_eq!(system_info_ui(&app), empty); - assert!(empty.iter().any(|line| line == "Grafický adaptér: auto")); - assert!(empty.iter().any(|line| line == "Preferovaný backend GPU: automaticky"), "{empty:?}"); + let cs = crate::i18n::current(); + 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.selected, "auto"); }); diff --git a/crates/ui-egui/src/history_menu_ui.rs b/crates/ui-egui/src/history_menu_ui.rs index cf95f18a01..da77f9d2fb 100644 --- a/crates/ui-egui/src/history_menu_ui.rs +++ b/crates/ui-egui/src/history_menu_ui.rs @@ -187,17 +187,6 @@ mod tests { 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] fn without_a_document_only_close_is_enabled() { let h = harness(PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default())); diff --git a/crates/ui-egui/src/i18n/live_tests.rs b/crates/ui-egui/src/i18n/live_tests.rs index e2150247b9..81639fa8d2 100644 --- a/crates/ui-egui/src/i18n/live_tests.rs +++ b/crates/ui-egui/src/i18n/live_tests.rs @@ -41,9 +41,10 @@ fn ukrainian_native_app_menu_keeps_product_and_language_names() { with_language(Lang::EN, || { let h = harness(); 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")] - { - assert_eq!(layout.bar.find(id).map(|item| item.label.as_str()), Some(label), "{id}"); + 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!(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)))); }); @@ -52,29 +53,21 @@ fn ukrainian_native_app_menu_keeps_product_and_language_names() { #[test] fn ukrainian_color_picker_titles_draw_in_the_selected_language() { 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(); h.state_mut().run("prefs.set", json!({"path": "interface.language", "value": "uk"})).unwrap(); crate::color_picker_ui::open(h.state_mut(), target); h.run_steps(4); - let text = drawn_text(&h); - assert!(text.iter().any(|text| text == expected), "missing title {expected}"); - assert!(!text.iter().any(|text| text.starts_with("Color Picker")), "no English picker title"); + let expected = tr(lang("uk"), title); + assert!(drawn_text(&h).iter().any(|text| text == expected), "missing title {expected}"); } - assert_eq!(tr(lang("uk"), "Color Picker (Custom Canvas Color)"), "Вибір кольору (власний колір полотна)"); }); } #[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; - 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, || { let mut h = harness(); 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 text = drawn_text(&h); 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] fn scoped_preview_restores_language_even_after_a_panic() { with_language(Lang::EN, || { with_language(lang("fr"), || { - assert_eq!(t("Layer"), "Calque"); - with_language(lang("ko"), || assert_eq!(t("Layer"), "레이어")); - assert_eq!(t("Layer"), "Calque"); + assert_eq!(current(), lang("fr")); + assert_eq!(t("Layer"), tr(lang("fr"), "Layer")); + 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"))); assert!(result.is_err()); @@ -120,20 +101,15 @@ fn drawing_languages_are_isolated_between_threads() { with_language(lang("fr"), || { let thread = std::thread::spawn(|| { 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"); - assert_eq!(t("Layer"), "Calque"); + assert_eq!(current(), lang("fr")); }); } #[test] -fn dynamic_brush_sections_have_translations_and_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()); - } - } +fn dynamic_brush_sections_draw_in_the_selected_language() { with_language(Lang::EN, || { let mut h = harness(); 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); let text = drawn_text(&h); // 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"); - assert!(!text.iter().any(|text| text == "Brush Tip Shape")); + let expected = tr(lang("zh-hans"), "Brush Tip Shape"); + assert_eq!(text.iter().filter(|text| *text == expected).count(), 1, "the section row must be translated"); }); } #[test] -fn fill_dialog_and_contour_choices_have_translations_and_draw_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()); - } - } +fn fill_dialog_draws_in_the_selected_language() { with_language(Lang::EN, || { let mut h = harness(); 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"); h.run_steps(4); 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 == "Contents:" || text == "Foreground Color")); }); } #[test] -fn file_info_camera_data_has_translations_and_draws_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()); - } - } +fn file_info_camera_data_draws_in_the_selected_language_read_only() { with_language(Lang::EN, || { let mut h = harness(); 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"); h.run_steps(4); 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:?}"); } // 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"); h.run_steps(3); let text = drawn_text(&h); - assert!(text.iter().any(|text| text == "Apparence"), "the draft language must translate the visible dialog"); - assert!(text.iter().any(|text| text == "Annuler"), "the dialog buttons must preview the language too"); + let fr = lang("fr"); + 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_eq!(h.state().session.prefs().interface.language, "auto"); 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(); 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 pt = lang("pt-br"); + let label = tr_id(pt, "file.saveAs", "Save As…"); // 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.run_steps(3); 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.trim() == "Salvar como…"), "{filter}: the command must be translated: {text:?}"); - assert!(!text.iter().any(|text| text.trim() == "Save As…"), "{filter}: no English command labels"); + assert!(text.iter().any(|text| text == tr(pt, "File")), "{filter}: the menu heading must be translated"); + assert!(text.iter().any(|text| text.trim() == label), "{filter}: the command must be translated: {text:?}"); } }); } @@ -432,13 +398,18 @@ fn czech_status_notices_and_navigator_draw_translated_text() { None, ); 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); 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). - for want in ["Navigátor", "Soubor English file.psd se nepodařilo otevřít: disk full", "Předvolby nelze uložit"] { - assert!(text.iter().any(|text| text == want), "missing {want}: {text:?}"); + for want in [ + 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. 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); let fields = &h.state().ui.dialogs.iter().find(|d| d.id == id).expect("dialog").fields; 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")); }); } diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index 0ff8a30432..22b9ff60c4 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -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 /// index. 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, catalog: OnceLock, } @@ -479,10 +479,6 @@ mod tests { assert_eq!(HI().name(), "हिन्दी"); 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] @@ -499,7 +495,6 @@ mod tests { assert_eq!(lang_from_tag(tag), Some(VI()), "{tag}"); } 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()); } @@ -578,87 +573,21 @@ mod tests { 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] - fn brush_size_and_hardness_shortcuts_are_translated_in_every_language() { - 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()); - } - } + fn simplified_chinese_resolves_from_preferences() { 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!(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"); } - /// 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] fn spanish_resolves_and_pluralises() { let es = Lang::from_code("es").expect("es registered"); for tag in ["es", "es_ES.UTF-8", "es-MX", "es-419"] { assert_eq!(lang_from_tag(tag), Some(es), "{tag}"); } - assert_eq!(tr(es, "Layer"), "Capa"); - assert_eq!(trn(es, 1, "{n} item", "{n} items"), "1 elemento"); - assert_eq!(trn(es, 3, "{n} item", "{n} items"), "3 elementos"); + let forms: Vec = [0, 1, 3].into_iter().map(es.0.plural).collect(); + assert_eq!(forms, [1, 0, 1]); } #[test] @@ -667,14 +596,9 @@ mod tests { for tag in ["hu", "hu_HU.UTF-8", "hu-HU"] { assert_eq!(lang_from_tag(tag), Some(hu), "{tag}"); } - assert!(hu.complete_menus()); assert_eq!(hu.name(), "Magyar"); - assert_eq!(tr(hu, "Layer"), "Réteg"); - for (n, expected) in [(0, "0 réteg"), (1, "1 réteg"), (3, "3 réteg"), (21, "21 réteg")] { - 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"); + let forms: Vec = [0, 1, 3, 21].into_iter().map(hu.0.plural).collect(); + assert_eq!(forms, [0, 0, 0, 0]); } #[test] @@ -684,241 +608,63 @@ mod tests { assert_eq!(lang_from_tag(tag), Some(pt), "{tag}"); } assert_eq!(candidates("pt_PT"), ["pt-pt", "pt-br", "pt"]); - assert_eq!(tr(pt, "Layer"), "Camada"); - assert_eq!(trn(pt, 0, "{n} item", "{n} items"), "0 item"); - assert_eq!(trn(pt, 1, "{n} item", "{n} items"), "1 item"); - assert_eq!(trn(pt, 2, "{n} item", "{n} items"), "2 itens"); + let forms: Vec = [0, 1, 2].into_iter().map(pt.0.plural).collect(); + assert_eq!(forms, [0, 0, 1]); } #[test] fn lookups_fall_back_to_english() { assert_eq!(tr(JA(), "no such label"), "no such label"); 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_id(ZH(), "no.such.id", "Layer"), "圖層"); - assert_eq!(tr_id(JA(), "no.such.id", "Layer"), "レイヤー"); - assert_eq!(tr_ctx(JA(), "no such context", "Layer"), "レイヤー"); + assert_eq!(tr_id(ZH(), "no.such.id", "Layer"), tr(ZH(), "Layer")); + assert_eq!(tr_id(JA(), "no.such.id", "Layer"), tr(JA(), "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] - fn russian_plural_rules() { - 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() { + fn russian_plural_rule() { let ru = Lang::from_code("ru").expect("ru registered"); - let cases = [ - ( - "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"), "Объект не найден — обведите его прямоугольником"); + let forms: Vec = [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]); } #[test] fn ukrainian_resolves_locales_and_preferences() { let uk = Lang::from_code("uk").expect("uk registered"); 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"] { assert_eq!(lang_from_tag(tag), Some(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"); } #[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"); - for (n, item, layer) in [ - (0, "елементів", "шарів"), - (1, "елемент", "шар"), - (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}")); - } + let counts = [0, 1, 2, 4, 5, 11, 12, 14, 19, 21, 22, 24, 25, 100, 101, 111, 112, 114, 121, 122, u64::MAX]; + let forms: Vec = counts.into_iter().map(uk.0.plural).collect(); + assert_eq!(forms, [2, 0, 1, 1, 2, 2, 2, 2, 2, 0, 1, 1, 2, 2, 0, 2, 2, 2, 0, 1, 2]); } #[test] fn serbian_resolves_locales_and_preferences() { let sr = Lang::from_code("sr").expect("sr registered"); 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"] { assert_eq!(lang_from_tag(tag), Some(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] - 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"); - for (n, item, layer) in [ - (0, "stavki", "slojeva"), - (1, "stavka", "sloj"), - (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); - } + let counts = [0, 1, 2, 4, 5, 11, 12, 14, 21, 22, 25, 101, 111, 112, 122]; + let forms: Vec = counts.into_iter().map(sr.0.plural).collect(); + assert_eq!(forms, [2, 0, 1, 1, 2, 2, 2, 2, 0, 1, 2, 0, 2, 2, 1]); } #[test] @@ -945,14 +691,14 @@ mod tests { 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, 7, "{n} item", "{n} items"), "7 items"); - assert_eq!(trn(JA(), 1, "{n} item", "{n} items"), "1 件"); - assert_eq!(trn(JA(), 7, "{n} item", "{n} items"), "7 件"); - assert_eq!(trn(ZH(), 1, "{n} item", "{n} items"), "1 個項目"); - assert_eq!(trn(ZH(), 7, "{n} item", "{n} items"), "7 個項目"); - assert_eq!(trn(CS(), 1, "{n} item", "{n} items"), "1 položka"); - assert_eq!(trn(CS(), 3, "{n} item", "{n} items"), "3 položky"); - assert_eq!(trn(CS(), 5, "{n} item", "{n} items"), "5 položek"); - assert_eq!(trn(CS(), 0, "{n} item", "{n} items"), "0 položek"); + let cs = fixture_lang(plural_cs, "@plural\t{n} item|{n} items\t{n} one|{n} few|{n} other\n"); + for (n, expected) in [(0, "0 other"), (1, "1 one"), (3, "3 few"), (5, "5 other")] { + assert_eq!(trn(cs, n, "{n} item", "{n} items"), expected); + } + let ja = fixture_lang(plural_none, "@plural\t{n} item|{n} items\t{n} only\n"); + assert_eq!(trn(ja, 1, "{n} item", "{n} items"), "1 only"); + assert_eq!(trn(ja, 7, "{n} item", "{n} items"), "7 only"); + 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("{missing}", &[]), "{missing}"); 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"] { assert_eq!(lang_from_tag(tag), Some(fr), "{tag}"); } - assert_eq!(tr(fr, "Layer"), "Calque"); - 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 = [0, 1, 2, 5, 100, u64::MAX].into_iter().map(plural_fr).collect(); + let forms: Vec = [0, 1, 2, 5, 100, u64::MAX].into_iter().map(fr.0.plural).collect(); 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] @@ -980,61 +739,10 @@ mod tests { 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] fn czech_plural_rule() { let forms: Vec = [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!(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. @@ -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)] diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 57b15fb8ad..709a425837 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1923,7 +1923,7 @@ mod tests { let items = menu_items(&app); 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::>(); - 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("zzzzqqq").is_empty()); } @@ -2252,7 +2252,7 @@ mod quick_export_label_tests { assert_eq!(quick[0].label, format!("Quick Export as {shown_format}")); let id = crate::i18n::Lang::from_code("id").unwrap(); 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(); 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); 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 expected = match 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); + assert_eq!(translated_menu_label(de, reveal[0]), crate::i18n::tr(de, reveal_label())); // 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")); } @@ -2371,7 +2366,7 @@ mod window_documents_tests { 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")); 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. diff --git a/crates/ui-egui/src/new_doc_ui.rs b/crates/ui-egui/src/new_doc_ui.rs index 4f09d6a028..563a9a5fca 100644 --- a/crates/ui-egui/src/new_doc_ui.rs +++ b/crates/ui-egui/src/new_doc_ui.rs @@ -1029,23 +1029,6 @@ mod tests { 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] fn recent_category_lists_clipboard_then_created_documents_then_uncovered_defaults() { let settings = |w, h, ppi| DocumentSettings { diff --git a/crates/ui-egui/src/notices.rs b/crates/ui-egui/src/notices.rs index 08de9f16ab..79e4dad3db 100644 --- a/crates/ui-egui/src/notices.rs +++ b/crates/ui-egui/src/notices.rs @@ -293,14 +293,9 @@ mod tests { let command = Some("WAYLAND_DISPLAY= photocraft".to_string()); let app = PhotocraftApp::new(Session::new(), Services { is_wayland: true, xwayland_command: command, ..Default::default() }); 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 current language); `tr` with an explicit language leaves the process-wide language, - // which other tests running in parallel draw with, untouched - let es = crate::i18n::Lang::from_code("es").unwrap(); - assert_eq!(crate::i18n::tr(es, ¬ice.title), "Arrastrar y soltar archivos de forma nativa no está disponible en Wayland"); - assert_ne!(crate::i18n::tr(es, ¬ice.lines[0]), notice.lines[0]); - assert_eq!(crate::i18n::tr(crate::i18n::Lang::EN, ¬ice.title), "Native file drag-and-drop is unavailable"); + // the current language) + assert_eq!(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 @@ -325,8 +320,6 @@ mod tests { assert_eq!(shown.len(), 1, "a second paste doesn't stack another notice"); let guidance = english(shown[0]); 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)); crate::menus::invoke(&mut empty, &ctx, "edit.paste", serde_json::json!({})).unwrap(); @@ -440,15 +433,16 @@ mod tests { #[test] fn import_warning_punctuation_is_localized_without_changing_english() { let mut app = PhotocraftApp::new(Session::new(), Services::default()); - for (lang, expected) in [ - (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 …"), - ] { + for lang in [crate::i18n::Lang::EN, crate::i18n::Lang::from_code("cs").unwrap()] { crate::i18n::with_language(lang, || { let what = crate::i18n::fmt(tl!("Opened {name}"), &[("name", "x.psd")]); let warnings = vec!["first warning".into(), "second warning".into(), "third warning".into()]; 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); io_warnings(&mut app, &what, &warnings[..1]); assert_eq!(app.ui.status, format!("{what}: first warning")); diff --git a/crates/ui-egui/src/plugin_ui.rs b/crates/ui-egui/src/plugin_ui.rs index 2b3a7f88c4..b581e6b423 100644 --- a/crates/ui-egui/src/plugin_ui.rs +++ b/crates/ui-egui/src/plugin_ui.rs @@ -103,9 +103,6 @@ mod tests { #[test] fn plugin_menu_path_stays_untranslated_in_other_languages() { 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 mut app = PhotocraftApp::new(photocraft_engine::Session::new(), crate::Services::default()); let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../plugins/tests/fixtures/invert.wasm"); diff --git a/crates/ui-egui/src/prefs_ui.rs b/crates/ui-egui/src/prefs_ui.rs index 9123e499e2..e7553ce31c 100644 --- a/crates/ui-egui/src/prefs_ui.rs +++ b/crates/ui-egui/src/prefs_ui.rs @@ -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>>) { app_with_saved(None) } diff --git a/crates/ui-egui/src/profile_open_tests.rs b/crates/ui-egui/src/profile_open_tests.rs index 42053729f4..651a93b3aa 100644 --- a/crates/ui-egui/src/profile_open_tests.rs +++ b/crates/ui-egui/src/profile_open_tests.rs @@ -144,32 +144,8 @@ fn files_opened_together_ask_one_at_a_time_before_each_opens() { #[test] fn the_prompt_is_translated_and_still_names_its_file() { let png = adobe_png(); - for (code, sentence, use_embedded, convert) in [ - ( - "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 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"] { let services = Services { 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()) @@ -183,16 +159,14 @@ fn the_prompt_is_translated_and_still_names_its_file() { assert!(app.session.documents().is_empty(), "{code}: held until answered"); let fields = app.ui.dialogs[0].fields.clone(); 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 (use_label, convert_label) = crate::i18n::with_language(lang, || { - ( - 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}"); + assert!(name("embedded").starts_with("Adobe RGB"), "{code}: {}", name("embedded")); + assert!(name("working").starts_with("sRGB"), "{code}: {}", name("working")); } } diff --git a/crates/ui-egui/src/shortcut_dispatch/tests.rs b/crates/ui-egui/src/shortcut_dispatch/tests.rs index 9bb0e5df24..df66185db1 100644 --- a/crates/ui-egui/src/shortcut_dispatch/tests.rs +++ b/crates/ui-egui/src/shortcut_dispatch/tests.rs @@ -310,9 +310,11 @@ fn a_disabled_shortcut_says_why() { #[test] fn a_disabled_shortcut_uses_the_command_id_translation() { 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"); - 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); }); } diff --git a/crates/ui-egui/src/stroke_ui_tests.rs b/crates/ui-egui/src/stroke_ui_tests.rs index b79306c3a3..1ef75e4d58 100644 --- a/crates/ui-egui/src/stroke_ui_tests.rs +++ b/crates/ui-egui/src/stroke_ui_tests.rs @@ -139,17 +139,6 @@ fn command_params_do_not_leak_private_dialog_fields() { 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 /// Photoshop), not egui's compact popup; OK sets the stroke colour, not the foreground, and the /// stroke is drawn in it. diff --git a/crates/ui-egui/src/symmetry_ui/tests.rs b/crates/ui-egui/src/symmetry_ui/tests.rs index 9f66449572..85fd45b0a3 100644 --- a/crates/ui-egui/src/symmetry_ui/tests.rs +++ b/crates/ui-egui/src/symmetry_ui/tests.rs @@ -184,9 +184,6 @@ fn overlays_follow_document_view_transforms_in_every_theme() { } 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 /// only ways out), and the canvas shows the move, rotate or arrow cursor instead of the brush. diff --git a/crates/ui-egui/src/type_tool_tests.rs b/crates/ui-egui/src/type_tool_tests.rs index e25baa6382..e6e3b66c83 100644 --- a/crates/ui-egui/src/type_tool_tests.rs +++ b/crates/ui-egui/src/type_tool_tests.rs @@ -65,7 +65,7 @@ fn numeric_type_fields_have_localized_accessible_names() { ); PhotocraftApp::setup_context(&h.ctx, Default::default()); 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.key_press_modifiers(Modifiers::COMMAND, egui::Key::A); h.event(egui::Event::Text("36".into())); diff --git a/crates/ui-egui/tests/it/camera_raw_histogram.rs b/crates/ui-egui/tests/it/camera_raw_histogram.rs index 144522908d..7b1c9502d0 100644 --- a/crates/ui-egui/tests/it/camera_raw_histogram.rs +++ b/crates/ui-egui/tests/it/camera_raw_histogram.rs @@ -80,7 +80,6 @@ fn histogram_matches_visible_preview_at_all_supported_depths_and_cancel_commit_a #[test] 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 { 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); diff --git a/docs/development.md b/docs/development.md index dc153c4e5a..4e7db51496 100644 --- a/docs/development.md +++ b/docs/development.md @@ -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 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 -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 diff --git a/docs/localization-es.md b/docs/localization-es.md index de47b78716..4212dcac71 100644 --- a/docs/localization-es.md +++ b/docs/localization-es.md @@ -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 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 -generated filter and adjustment surface and fails if a future label has no Spanish row. A few +A future generated label without a Spanish row shows in English. `cargo xtask i18n-coverage` +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. ## Terminology diff --git a/docs/localization-it.md b/docs/localization-it.md index f983c499e4..bb82fee489 100644 --- a/docs/localization-it.md +++ b/docs/localization-it.md @@ -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 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 -mode and would have given two different things the same name. The wording is pinned by a test -(`i18n::tests::italian_names_the_dodge_tools_and_the_screen_blend_mode_as_photoshop_does`), which -also keeps the tool family together. +mode and would have given two different things the same name. Keep the tool family's names +together when editing these rows. 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 & @@ -69,10 +68,8 @@ current code; they are translated to stay aligned with the other catalogs. ## Validation -- `italian_covers_generated_filter_options_and_gallery_names` (`crates/ui-egui/src/filter_dialog.rs`) - enumerates every filter and adjustment parameter, symbolic choice and gallery name, like the - Korean test, and requires an Italian row for each. With the catalog on `main` before this change - it lists 388 missing labels; now none. +- Every filter and adjustment parameter, symbolic choice and gallery name has an Italian row. The + catalog on `main` before this change lacked 388 of them. - `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 `properties_edit_shown_metrics_and_paragraphs_with_a_non_type_primary`, fail on `main` as well diff --git a/docs/localization-ko.md b/docs/localization-ko.md index 0cf8fc97b0..3b21840305 100644 --- a/docs/localization-ko.md +++ b/docs/localization-ko.md @@ -23,14 +23,11 @@ resources, fonts, screenshots or user documents are included. Command-ID translations disambiguate menu names from actions. Photoshop terms are used when the operation matches; PhotoCraft-specific features retain their own meaning. Technology names, units and user-provided names are not translated. -The terminology test protects representative tool names and this distinction. ## Dynamic coverage Generated filter parameter names, symbolic choices, gallery categories/effects, 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 all nine non-English catalogs. Existing fallback behavior is preserved for untranslated dynamic labels in other languages. diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 9f5d6cd066..2ea5a9a0b6 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -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 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 -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, ja, ko, nb, nl, pl, pt-BR, ru, sk, sv, tr, uk, zh-Hans, zh-Hant (24 `.lproj` markers; one pack is diff --git a/docs/localization-ru.md b/docs/localization-ru.md index ea0eff64df..f3a68f8540 100644 --- a/docs/localization-ru.md +++ b/docs/localization-ru.md @@ -63,12 +63,8 @@ translated to stay aligned with the other catalogs. ## Validation -- `russian_covers_generated_filter_options_and_gallery_names` (`crates/ui-egui/src/filter_dialog.rs`) - enumerates every filter and adjustment parameter, symbolic choice and gallery name and requires - 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. +- Every filter and adjustment parameter, symbolic choice and gallery name has a Russian row, as do + the rasterize prompts, hardware status lines and composed status templates. - `cargo test -p photocraft-ui-egui --lib`: 2,998 passed, 0 failed. - `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 diff --git a/docs/localization-zh-hans.md b/docs/localization-zh-hans.md index d92829f36e..9a84589589 100644 --- a/docs/localization-zh-hans.md +++ b/docs/localization-zh-hans.md @@ -28,8 +28,8 @@ language manually there. - `crates/ui-egui/src/i18n/zh-hans.tsv` contains the translations, independent of the lookup implementation. Its UTF-8 columns are `contextsourcetranslation`. - `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, - `tl!` literal, blend mode, brush section and generated preference label. + `complete_menus: true`. `cargo xtask i18n-coverage` lists any menu string, + `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 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, `cargo xtask layers` and `cargo xtask wasm`. The shared catalog tests validate -duplicate keys, placeholders, ellipses, menu coverage, `tl!` literals, brush -sections and blend modes. Chinese-specific tests cover locale selection, fallback, -plural messages, formatted labels and the painted brush section rows and heading. Render and inspect the menus, Preferences and representative +duplicate keys, placeholders, ellipses and plural forms. Chinese-specific tests +cover locale selection, fallback and the painted brush section rows. Render and inspect the menus, Preferences and representative dialogs with the existing offscreen `snapshot` example. 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. When English labels change, update their source keys and translations together, -preserving contexts and placeholders. The complete-catalog tests catch missing -keys, and the Simplified Chinese regression test checks dynamic shortcut labels, -layer counts and English fallback. Review changed source meanings against the -current English UI. +preserving contexts and placeholders. `cargo xtask i18n-coverage` (also the last +step of `cargo xtask ci`) lists the keys still missing. Review changed source +meanings against the current English UI. diff --git a/docs/localization.md b/docs/localization.md index 5a1355057c..30fe04725a 100644 --- a/docs/localization.md +++ b/docs/localization.md @@ -31,7 +31,9 @@ contextual, command-ID and plural translations; missing entries fall back to Eng | हिन्दी | `hi` | one / other | 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 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 @@ -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 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. -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. ```sh diff --git a/docs/ui-design.md b/docs/ui-design.md index 8ac01f8e01..24a30a16ed 100644 --- a/docs/ui-design.md +++ b/docs/ui-design.md @@ -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. 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. - Japanese (`ja`), complete. @@ -225,15 +225,16 @@ the tests enforce it): - To add a language: add `.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, 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. - Translations are clean-room: written from the meaning of the English text in ordinary vocabulary, never from another product's localisation resources. 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 -themselves). A test fails when a `tl!` literal, a menu string, a blend mode name, brush section name or a generated -preference label has no entry in a language marked `complete_menus`. Not translated: status-bar +themselves). A string with no entry in a catalog shows in English; `cargo xtask i18n-coverage` +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, 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`), @@ -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 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 -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 languages; font delivery on the web remains separate work.