mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-24 01:47:08 -06:00
Tests: missing translations don't fail; xtask ci prints them
Delete the coverage tests, the per-language parity checks and the tests of one language's exact wording. Catalog integrity, placeholder and plural-form checks stay, as do the engine tests (locale resolution, plural rules, fallback, language scoping, drawing in a language); those compare against tr() or the plural rule rather than catalog wording. Docs describe the report instead of a failing test.
This commit is contained in:
@@ -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`.
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String> = shown.iter().zip(&buttons).map(|(text, (_, key))| format!("{text} ({})", key.map_or("", |k| k.name()))).collect();
|
||||
assert!(labels.iter().all(|label| label.starts_with('(')) || labels == appended, "{labels:?}");
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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::<Vec<_>>(),
|
||||
[(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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String> {
|
||||
let mut missing = std::collections::BTreeSet::new();
|
||||
let mut check = |s: String| {
|
||||
if !matches!(s.as_str(), "X" | "Y" | "A" | "B") && !crate::i18n::has(lang, &s) {
|
||||
missing.insert(s);
|
||||
}
|
||||
};
|
||||
for c in photocraft_engine::command_specs().iter().filter(|c| c.id.starts_with("filter.") || c.id.starts_with("image.adjustments.")) {
|
||||
for p in parse_spec(c.params) {
|
||||
if !p.key.chars().all(|c| c.is_ascii_alphanumeric()) || matches!(p.kind, Kind::Json) {
|
||||
continue;
|
||||
}
|
||||
check(source_label(&p.key));
|
||||
if let Kind::Choice { options, .. } = &p.kind {
|
||||
for choice in options {
|
||||
// Only symbolic options are UI choices; registry docs also contain
|
||||
// colour syntax and array notation, which are not translatable names.
|
||||
if choice.chars().all(|c| c.is_ascii_alphanumeric()) {
|
||||
check(source_label(choice));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for f in photocraft_algo::GalleryFilter::ALL {
|
||||
check(f.name().to_string());
|
||||
}
|
||||
for cat in photocraft_algo::GALLERY_CATEGORIES {
|
||||
check(cat.to_string());
|
||||
}
|
||||
missing
|
||||
}
|
||||
|
||||
/// 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<String>>::new();
|
||||
for lang in crate::i18n::Lang::all().filter(|l| l.code() != "en") {
|
||||
let mut check = |s: String| {
|
||||
if !crate::i18n::has(lang, &s) {
|
||||
missing.entry(lang.code()).or_default().insert(s);
|
||||
}
|
||||
};
|
||||
for c in photocraft_engine::command_specs().iter().filter(|c| c.id.starts_with("select.") && has_dialog(c.id)) {
|
||||
let title = c.label.trim_end_matches('…');
|
||||
if !crate::i18n::has(lang, title) {
|
||||
check(format!("{title}…"));
|
||||
}
|
||||
for p in parse_spec(c.params) {
|
||||
check(source_label(&p.key));
|
||||
if let Kind::Choice { options: choices, .. } = p.kind {
|
||||
choices.iter().for_each(|choice| check(source_label(choice)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(missing.is_empty(), "untranslated Select dialog labels: {missing:#?}");
|
||||
}
|
||||
|
||||
/// Select and Mask's Output To lists all six of Photoshop's targets, each translated.
|
||||
#[test]
|
||||
fn select_and_mask_output_choices_are_translated() {
|
||||
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<String> = choices.iter().map(|c| source_label(c)).collect();
|
||||
assert_eq!(labels, ["Selection", "Layer Mask", "New Layer", "New Layer With Mask", "New Document", "New Document With Mask"]);
|
||||
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]
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
}
|
||||
|
||||
+51
-765
@@ -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<Catalog>,
|
||||
}
|
||||
@@ -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<usize> = [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<usize> = [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<usize> = [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<usize> = [0, 1, 2, 5, 11, 12, 14, 21, 22, 25, 101, 111, 112].into_iter().map(ru.0.plural).collect();
|
||||
assert_eq!(forms, [2, 0, 1, 2, 2, 2, 2, 0, 1, 2, 0, 2, 2]);
|
||||
}
|
||||
|
||||
#[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<usize> = 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<usize> = 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<usize> = [0, 1, 2, 5, 100, u64::MAX].into_iter().map(plural_fr).collect();
|
||||
let forms: Vec<usize> = [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<usize> = [0, 1, 2, 3, 4, 5, 11, 12, 21, 22, 100, u64::MAX].into_iter().map(plural_cs).collect();
|
||||
assert_eq!(forms, [2, 0, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2]);
|
||||
assert_eq!(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)]
|
||||
|
||||
@@ -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::<Vec<_>>();
|
||||
assert!(!ids("calque").is_empty(), "French menu name");
|
||||
assert!(!ids(&crate::i18n::tr(fr, "Layer").to_lowercase()).is_empty(), "French menu name");
|
||||
assert!(ids("distort").contains(&"edit.transform.distort".to_string()), "English still matches");
|
||||
assert!(ids("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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -3217,34 +3217,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Every generated preference label, section title and choice label has an entry in each
|
||||
/// language that claims complete menus (Japanese, Traditional Chinese, ...).
|
||||
#[test]
|
||||
fn preference_labels_are_translated() {
|
||||
let session = photocraft_engine::Session::new();
|
||||
let v: Value = serde_json::from_str(&session.prefs_to_json()).expect("prefs json");
|
||||
for lang in crate::i18n::Lang::all().filter(|l| l.complete_menus()) {
|
||||
let mut missing = Vec::new();
|
||||
for (sec, title) in SECTIONS {
|
||||
if !crate::i18n::has(lang, title) {
|
||||
missing.push(title.to_string());
|
||||
}
|
||||
let Some(obj) = v.get(sec).and_then(Value::as_object) else { continue };
|
||||
for k in obj.keys() {
|
||||
if sec == "interface" && k == "favoriteThemes" {
|
||||
continue; // Rendered as star controls beside the theme choices.
|
||||
}
|
||||
let mut labels = vec![humanize(k)];
|
||||
labels.extend(prefs::choices(&format!("{sec}.{k}")).into_iter().flatten().map(|c| choice_label(c)));
|
||||
missing.extend(labels.into_iter().filter(|l| !crate::i18n::has(lang, l)));
|
||||
}
|
||||
}
|
||||
missing.sort();
|
||||
missing.dedup();
|
||||
assert!(missing.is_empty(), "{}: untranslated preference labels: {missing:#?}", lang.code());
|
||||
}
|
||||
}
|
||||
|
||||
fn app_with_store() -> (PhotocraftApp, Arc<Mutex<Option<String>>>) {
|
||||
app_with_saved(None)
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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);
|
||||
|
||||
+5
-1
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 `context<TAB>source<TAB>translation`.
|
||||
- `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.
|
||||
|
||||
@@ -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
|
||||
|
||||
+6
-5
@@ -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 `<code>.tsv` and one row in `i18n::LANGUAGES` (code, native name, catalog,
|
||||
plural rule). The dropdown, locale matching and the catalog tests (well-formed, no duplicates,
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user