From 6d8c0bb8dd1319ca4613ff96fbc6a8609fcbffec Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 18:18:12 +0000 Subject: [PATCH] refactor(ui-egui): move i18n.rs's tests to i18n/tests.rs --- crates/ui-egui/src/i18n.rs | 1230 +----------------------------- crates/ui-egui/src/i18n/tests.rs | 1227 +++++++++++++++++++++++++++++ 2 files changed, 1228 insertions(+), 1229 deletions(-) create mode 100644 crates/ui-egui/src/i18n/tests.rs diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 6214348f6..3f478b637 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -780,1235 +780,7 @@ fn name_sort_key(name: &str) -> String { } #[cfg(test)] -mod tests { - use super::catalog::{Entry, parse_entries, placeholders}; - use super::*; - - const JA: fn() -> Lang = || Lang::from_code("ja").expect("ja registered"); - - #[test] - fn keyboard_key_names_have_a_separate_context_and_english_fallback() { - static KEYS: LangInfo = LangInfo { - code: "keys", - name: "Key name test catalog", - source: "key\tDelete\tEntf\n\tDelete\tLöschen\n\tOpen\tÖffnen\n", - plural: plural_one_other, - rtl: false, - catalog: OnceLock::new(), - }; - let previous = current(); - set_current(Lang(&KEYS)); - assert_eq!(key_name("Delete"), "Entf"); - assert_eq!(t("Delete"), "Löschen"); - assert_eq!(key_name("Open"), "Open", "a missing key name must not borrow a command translation"); - set_current(Lang::EN); - assert_eq!(key_name("Delete"), "Delete"); - set_current(previous); - } - - /// Regression test: a language picker uses its native name only when a face can draw it, otherwise its - /// English name. - #[test] - fn picker_names_fall_back_to_english_when_their_script_has_no_face() { - let hi = Lang::from_code("hi").expect("hi registered"); - assert_eq!(hi.picker_name(|_| true), "हिन्दी", "the native name is kept when it can be drawn"); - assert_eq!(hi.picker_name(|_| false), "Hindi", "no Devanagari face: an English name, not an unrenderable label"); - assert_eq!(Lang::EN.picker_name(|_| false), "English"); - for language in Lang::all() { - assert!(!language.english_name().is_empty(), "{}", language.code()); - assert_ne!(language.english_name(), language.code(), "{} needs an English picker fallback", language.code()); - } - } - - #[test] - fn tags_map_to_languages() { - assert_eq!(lang_from_tag("ja_JP.UTF-8"), Some(JA())); - assert_eq!(lang_from_tag("ja-JP"), Some(JA())); - assert_eq!(lang_from_tag("en_US.UTF-8"), Some(Lang::EN)); - assert_eq!(lang_from_tag("C"), Some(Lang::EN)); - assert_eq!(lang_from_tag("POSIX"), Some(Lang::EN)); - assert_eq!(lang_from_tag("fr_FR"), Lang::from_code("fr")); - assert_eq!(lang_from_tag("fr_CA.UTF-8"), Lang::from_code("fr")); - assert_eq!(lang_from_tag("fr-BE"), Lang::from_code("fr")); - assert_eq!(lang_from_tag("zh-TW"), Lang::from_code("zh-hant")); - assert_eq!(lang_from_tag("zh_CN.UTF-8"), Lang::from_code("zh-hans")); - assert_eq!(lang_from_tag("zh"), Lang::from_code("zh-hans")); - assert_eq!(lang_from_tag("zh_HK.UTF-8"), Lang::from_code("zh-hant")); - assert_eq!(lang_from_tag("zh-Hant-MO"), Lang::from_code("zh-hant")); - assert_eq!(lang_from_tag("lo_LA.UTF-8"), Lang::from_code("lo")); - assert_eq!(lang_from_tag("lo-LA"), Lang::from_code("lo")); - assert_eq!(lang_from_tag("lo"), Lang::from_code("lo")); - assert_eq!(lang_from_tag(""), None); - assert_eq!(lang_from_tag("_"), None); - } - - #[test] - fn candidates_walk_from_specific_to_general() { - assert_eq!(candidates("pt_BR.UTF-8"), ["pt-br", "pt"]); - assert_eq!(candidates("pt_PT.UTF-8"), ["pt-pt", "pt-br", "pt"]); - assert_eq!(candidates("pt"), ["pt-br", "pt"]); - assert_eq!(candidates("zh_TW"), ["zh-tw", "zh-hant", "zh"]); - assert_eq!(candidates("zh-CN"), ["zh-cn", "zh-hans", "zh"]); - assert_eq!(candidates("zh-Hant-HK"), ["zh-hant-hk", "zh-hant", "zh"]); - assert_eq!(candidates("lo_LA.UTF-8"), ["lo-la", "lo"]); - assert_eq!(candidates("nb_NO.UTF-8"), ["nb-no", "nb"]); - assert_eq!(candidates("no_NO.UTF-8"), ["no-no", "no", "nb"]); - assert_eq!(candidates("nn-NO"), ["nn-no", "nn", "nb"]); - } - - #[test] - fn macos_language_list_is_parsed() { - assert_eq!(first_supported("(\n \"ja-JP\",\n \"en-US\"\n)\n"), Some(JA())); - assert_eq!(first_supported("(\n \"fr-FR\",\n \"en-US\"\n)\n"), Lang::from_code("fr")); - assert_eq!(first_supported("("), None); - } - - /// #2954: the web build's Auto language follows the browser's preferred languages in order, - /// skipping ones without a catalog, and is English only when none is supported. - #[test] - fn browser_language_list_is_followed_in_order() { - assert_eq!(first_supported_tag(["it-IT", "en-US"]), Lang::from_code("it")); - assert_eq!(first_supported_tag(["xx-YY", "ja", "fr-FR"]), Some(JA())); - assert_eq!(first_supported_tag(["zh-TW"]), Lang::from_code("zh-hant")); - assert_eq!(first_supported_tag(["en-GB", "fr-FR"]), Some(Lang::EN)); - assert_eq!(first_supported_tag([" pt-PT "]), Lang::from_code("pt-br")); - assert_eq!(first_supported_tag(["", "xx"]), None); - assert_eq!(first_supported_tag(Vec::::new()), None); - let long = std::iter::repeat_n("xx", 10_000).chain(["fr"]); - assert_eq!(first_supported_tag(long), None, "only the first tags are read"); - } - - #[test] - fn windows_display_language_resolves_chinese_scripts() { - for tag in ["zh-CN", "zh-SG", "zh-Hans", "zh-Hans-CN"] { - assert_eq!(first_supported(&format!("{tag}\r\n")), Lang::from_code("zh-hans")); - } - for tag in ["zh-TW", "zh-HK", "zh-MO", "zh-Hant", "zh-Hant-HK"] { - assert_eq!(first_supported(&format!("{tag}\r\n")), Lang::from_code("zh-hant")); - } - assert_eq!(first_supported("en-US\r\n"), Some(Lang::EN)); - assert_eq!(first_supported("fr-FR\r\n"), Lang::from_code("fr")); - assert_eq!(first_supported("lo-LA\r\n"), Lang::from_code("lo")); - assert_eq!(first_supported("\r\n"), None); - } - - #[cfg(target_os = "windows")] - #[test] - fn neutral_locale_does_not_override_display_language() { - let fr = Lang::from_code("fr").unwrap(); - for tag in ["C", "C.UTF-8", "POSIX", "posix.UTF-8", "", " C.UTF-8 ", "unsupported"] { - assert_eq!(language_override(tag), None, "{tag}"); - assert_eq!(language_override(tag).or_else(|| first_supported("unsupported-XY\nfr-FR\nen-US")), Some(fr)); - } - assert_eq!(["C.UTF-8", "POSIX", "fr_FR.UTF-8"].into_iter().find_map(language_override), Some(fr)); - assert_eq!(language_override("fr_FR.UTF-8"), Some(fr)); - assert_eq!(language_override("en_US.UTF-8"), Some(Lang::EN)); - assert_eq!(language_override("ja_JP.UTF-8"), Some(JA())); - } - - #[cfg(target_os = "windows")] - #[test] - fn windows_display_language_query_returns_a_locale_tag() { - // One tag per line, as `first_supported` reads them: a machine with several display - // languages reports them all, so each line is checked rather than the list as a whole. - let list = windows_ui_language().expect("Windows should report a display language"); - let tags: Vec<&str> = list.lines().map(str::trim).filter(|t| !t.is_empty()).collect(); - assert!(!tags.is_empty(), "no display language in {list:?}"); - for tag in tags { - assert!(tag.split('-').all(|part| !part.is_empty() && part.bytes().all(|b| b.is_ascii_alphanumeric())), "not a locale tag: {tag:?}"); - } - } - - #[test] - fn preferences_resolve_with_fallback() { - assert_eq!(Lang::from_pref("ja"), JA()); - assert_eq!(Lang::from_pref("JA"), JA()); - assert_eq!(Lang::from_pref("en"), Lang::EN); - // `auto` and unknown codes follow the system (English under test). - assert_eq!(Lang::from_pref(AUTO), Lang::EN); - assert_eq!(Lang::from_pref("xx-unknown"), Lang::EN); - assert_eq!(normalize_pref("JA"), Some("ja")); - assert_eq!(normalize_pref("Auto"), Some(AUTO)); - assert_eq!(normalize_pref("xx"), None); - } - - #[test] - fn lookups_fall_back_to_english() { - assert_eq!(tr(JA(), "no such label"), "no such label"); - assert_eq!(tr(Lang::EN, "File"), "File"); - assert_eq!(tr(JA(), "File"), "ファイル"); - assert_eq!(tr(JA(), "日本語の文書.pdf"), "日本語の文書.pdf"); - assert_eq!(tr_id(JA(), "no.such.id", "File"), "ファイル"); - assert_eq!(tr_ctx(JA(), "no such context", "File"), "ファイル"); - assert!(has(JA(), "Save") && !has(Lang::EN, "Save")); - } - - #[test] - fn catalog_kinds_are_parsed_and_looked_up() { - let (c, errors) = Catalog::parse( - "# c\n\tHello\tこんにちは\n@id\tfile.save\t保存する\nmenu\tWindows\tウィンドウ群\n@plural\t{n} file|{n} files\t{n} 個\n\n", - 1, - ); - assert!(errors.is_empty(), "{errors:?}"); - assert_eq!(c.plain("Hello"), Some("こんにちは")); - assert_eq!(c.id("file.save"), Some("保存する")); - assert_eq!(c.contextual("menu", "Windows"), Some("ウィンドウ群")); - assert_eq!(c.contextual("other", "Windows"), None); - assert_eq!(c.plural("{n} file", "{n} files", 0), Some("{n} 個")); - assert_eq!(c.plural("{n} file", "{n} files", 5), Some("{n} 個"), "an index past the forms clamps"); - } - - #[test] - fn malformed_lines_are_reported_not_fatal() { - let (entries, errors) = parse_entries("\tok\tはい\nno tabs here\n\tonly\n\ta\tb\tc\textra\n\t\tempty source\n", 1); - assert_eq!(entries.len(), 1); - assert_eq!(errors.len(), 4, "{errors:?}"); - let (entries, errors) = parse_entries("\ta\\tb\tx\\ny\\\\z\n", 1); - assert!(errors.is_empty(), "{errors:?}"); - assert_eq!(entries, [Entry { context: String::new(), source: "a\tb".into(), translation: "x\ny\\z".into() }]); - } - - #[test] - fn strict_validation_skips_only_the_bad_line() { - for (text, forms, error) in [ - ("\tFile", 1, "context"), - ("\tFile\t", 1, "nonempty"), - ("\tFile\\q\tFiles", 1, "unknown escape"), - ("\tFile\tFiles\\", 1, "trailing backslash"), - ("\tOpen…\tOpen", 1, "ellipsis"), - ("\tOpen {file}\tOpen {name}", 1, "placeholders"), - ("@plural\tpage\tPages", 1, "one|other"), - ("@plural\t{n} page|{n} pages\t{n} pages", 2, "2 nonempty"), - ("@plural\t{n} page|{n} pages\tPages", 1, "placeholders"), - ("@unknown\tFile\tFiles", 1, "reserved context"), - ] { - // The bad line comes first; the good line after it still loads. - let (catalog, errors) = Catalog::parse(&format!("{text}\n\tSave\t保存\n"), forms); - assert_eq!(errors.len(), 1, "{text:?}: {errors:?}"); - assert!(errors[0].starts_with("line 1: ") && errors[0].contains(error), "{errors:?}"); - assert_eq!(catalog.plain("Save"), Some("保存"), "{text:?}"); - } - let (catalog, errors) = Catalog::parse("\tFile\tファイル\n\tFile\t別\n", 1); - assert!(errors.len() == 1 && errors[0].contains("line 2: duplicate"), "{errors:?}"); - assert_eq!(catalog.plain("File"), Some("ファイル"), "the first entry wins"); - } - - #[test] - fn catalogs_handle_escapes_crlf_and_distinct_contexts() { - let (catalog, errors) = - Catalog::parse("# comment\r\n\r\n\tLine\\nTab\\tPath\\\\\tOther\\nTab\\tPath\\\\\r\nweight\tLight\tThin\r\ntheme\tLight\tPale\r\n", 1); - assert!(errors.is_empty(), "{errors:?}"); - assert_eq!(catalog.plain("Line\nTab\tPath\\"), Some("Other\nTab\tPath\\")); - assert_eq!(catalog.contextual("weight", "Light"), Some("Thin")); - assert_eq!(catalog.contextual("theme", "Light"), Some("Pale")); - } - - #[test] - fn broken_catalog_falls_back_to_english() { - static BROKEN: LangInfo = LangInfo { - code: "broken", - name: "Broken test catalog", - source: "malformed\n\\", - plural: plural_none, - rtl: false, - catalog: OnceLock::new(), - }; - assert_eq!(tr(Lang(&BROKEN), "File"), "File"); - assert_eq!(trn(Lang(&BROKEN), 2, "{n} page", "{n} pages"), "2 pages"); - } - - #[test] - fn contextual_id_and_plural_lookups_have_english_fallbacks() { - static TEST: LangInfo = LangInfo { - code: "test", - name: "Test catalog", - source: "\tLight\tPlain light\nweight\tLight\tThin\n@id\tfile.open\tOpen dialog…\n@plural\t{n} page|{n} pages\tOne page: {n}|Many pages: {n}\n", - plural: plural_one_other, - rtl: false, - catalog: OnceLock::new(), - }; - let l = Lang(&TEST); - assert_eq!(tr_ctx(l, "weight", "Light"), "Thin"); - assert_eq!(tr_ctx(l, "theme", "Light"), "Plain light"); - assert_eq!(tr_ctx(l, "theme", "Unknown"), "Unknown"); - assert_eq!(tr_id(l, "file.open", "Reworded English…"), "Open dialog…"); - assert_eq!(tr_id(l, "unknown", "Light"), "Plain light"); - assert_eq!(tr_id(l, "unknown", "Unknown"), "Unknown"); - assert_eq!(trn(l, 1, "{n} page", "{n} pages"), "One page: 1"); - assert_eq!(trn(l, 0, "{n} page", "{n} pages"), "Many pages: 0"); - assert_eq!(trn(l, u64::MAX, "{n} page", "{n} pages"), format!("Many pages: {}", u64::MAX)); - } - - #[test] - fn formatting_reorders_parameters_without_reinterpreting_user_text() { - let args = [("file", "日本語-{n}.pdf"), ("n", "2")]; - assert_eq!(fmt("{n}: {file}; {unknown}", &args), "2: 日本語-{n}.pdf; {unknown}"); - assert_eq!(fmt("{file} / {file}", &args), "日本語-{n}.pdf / 日本語-{n}.pdf"); - assert_eq!(fmt("text {unfinished", &args), "text {unfinished"); - assert_eq!(fmt("}{n}{", &args), "}2{"); - assert_eq!(fmt("unchanged", &[]), "unchanged"); - } - - /// Every literal passed to `tl!(…)` or `crate::i18n::t(…)` in the UI source (test modules - /// aside), unescaped, including literals on a line of their own. - fn ui_literals() -> std::collections::BTreeSet { - let mut stack = vec![std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src")]; - let mut literals = std::collections::BTreeSet::new(); - while let Some(dir) = stack.pop() { - for entry in std::fs::read_dir(dir).expect("UI source directory") { - let path = entry.expect("UI source entry").path(); - if path.is_dir() { - if path.file_name().is_some_and(|name| name != "i18n") { - stack.push(path); - } - } else if path.extension().is_some_and(|ext| ext == "rs") { - let source = std::fs::read_to_string(path).expect("UI source file").replace("\r\n", "\n"); - let source = source.replace("crate::i18n::t(", "tl!("); - let mut rest = source.split("#[cfg(test)]\nmod ").next().unwrap_or_default(); - while let Some((_, after)) = rest.split_once("tl!(") { - let after = after.trim_start(); - let Some(after) = after.strip_prefix('"') else { - rest = after; - continue; - }; - let mut escaped = false; - let end = after - .char_indices() - .find_map(|(i, c)| { - if c == '"' && !escaped { - return Some(i); - } - escaped = c == '\\' && !escaped; - None - }) - .expect("closed tl! literal"); - let (raw, tail) = after.split_at(end); - let argument_end = tail.strip_prefix('"').expect("closing quote").trim_start(); - let literal_call = - argument_end.starts_with(')') || argument_end.strip_prefix(',').is_some_and(|rest| rest.trim_start().starts_with(')')); - if literal_call { - let label: String = serde_json::from_str(&format!("\"{raw}\"")).expect("UI literal escapes"); - literals.insert(label); - } - rest = tail.strip_prefix('"').expect("closing quote"); - } - } - } - } - literals - } - - /// Each translated label of a visible signature that runs into its value keeps a separator - /// at its end. - #[test] - fn signature_appearance_labels_keep_their_separator() { - let english = pdfcraft_engine::sign::AppearanceLabels::default(); - for language in LANGUAGES.iter().filter(|language| !language.source.is_empty()) { - let lang = Lang::from_code(language.code).expect("registered language"); - for (field, label) in english.fields() { - let said = tr(lang, label); - if field != "signed_by" && !lang.rtl() { - assert!(said.ends_with(' ') || said.ends_with(':'), "{}: the {field} label runs into its value: {said:?}", language.code); - } - } - } - } - - /// With craft-fonts, every Japanese translation has glyphs: with all interface faces (desktop) - /// and with BIZ UDPGothic Regular alone (the web build's only Japanese face). - #[test] - fn japanese_labels_have_glyphs() { - let craft = pdfcraft_fonts::ui_japanese_fonts(); - let Some(web) = craft.iter().find(|f| f.family == "BIZ UDPGothic" && f.style == "Regular") else { - eprintln!("skipping Japanese glyph checks: build with CRAFT_FONTS_DIR to run them"); - return; - }; - let (entries, _) = parse_entries(JA().0.source, 1); - let labels: String = entries.iter().flat_map(|e| e.translation.chars()).chain(JA().name().chars()).filter(|c| !c.is_control()).collect(); - let web_name = web.name(); - let mut web_only = crate::theme::font_definitions(); - for family in web_only.families.values_mut() { - family.retain(|name| !pdfcraft_fonts::CRAFT_FONTS.iter().any(|face| face.name() == *name) || *name == web_name); - } - use egui::epaint::text::{Fonts, TextOptions}; - for (build, defs) in [("desktop", crate::theme::font_definitions()), ("web", web_only)] { - let mut fonts = Fonts::new(TextOptions::default(), defs); - for id in [egui::FontId::proportional(13.0), egui::FontId::monospace(13.0), crate::theme::medium(13.0), crate::theme::semibold(17.0)] { - assert!(fonts.has_glyphs(&id, &labels), "{build}: {id:?} lacks a Japanese label glyph"); - } - } - } - - #[test] - fn history_labels_keep_captured_names() { - set_current(JA()); - assert_eq!(action_label("Insert pages from Save {e}.pdf"), "Save {e}.pdf からページを挿入"); - assert_eq!(command_label("Undo Insert pages from Save {e}.pdf"), "取り消し Save {e}.pdf からページを挿入"); - assert_eq!(action_label("Untranslated custom action"), "Untranslated custom action"); - assert_eq!(menu_label("file.open", "Open…"), "開く…"); - set_current(Lang::EN); - assert_eq!(command_label("Undo Fill in Save {e}"), "Undo Fill in Save {e}"); - } - - #[test] - fn current_language_is_per_thread() { - set_current(JA()); - assert_eq!(t("File"), "ファイル"); - std::thread::spawn(|| assert_eq!(current(), Lang::EN)).join().unwrap(); - set_current(Lang::EN); - assert_eq!(t("File"), "File"); - } - - #[test] - fn plurals_and_placeholders() { - 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"); - // No catalog has this `@plural` entry: Japanese falls back to the English forms. - assert_eq!(trn(JA(), 7, "{n} test item", "{n} test items"), "7 test items"); - 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"]); - } - - #[test] - fn language_persists_and_invalid_input_keeps_current_language() { - let mut app = crate::PdfCraftApp::default(); - assert_eq!(app.language, AUTO); - app.set_option("language", "JA").unwrap(); - assert_eq!(app.language, "ja"); - assert!(app.set_option("language", "xx").is_err()); - assert_eq!(app.language, "ja"); - let mut restored = crate::PdfCraftApp::default(); - restored.restore(&app.persist()); - assert_eq!(restored.language, "ja"); - restored.restore(r#"{"language":"xx"}"#); - assert_eq!(restored.language, "ja"); - app.set_option("language", AUTO).unwrap(); - assert_eq!(app.language, AUTO); - // Settings written before the system-locale default (no key) follow the system. - let mut legacy = crate::PdfCraftApp::default(); - legacy.restore("{}"); - assert_eq!(legacy.language, AUTO); - } - - #[test] - fn brazilian_portuguese_is_registered() { - let pt = Lang::from_code("pt-br").expect("pt-br registered"); - assert_eq!(pt.name(), "Português (Brasil)"); - assert_eq!(Lang::from_code("PT-BR"), Some(pt)); - assert_eq!(normalize_pref("pt-BR"), Some("pt-br")); - assert_eq!(normalize_pref("pt"), None, "only exact codes are preferences"); - assert_eq!(lang_from_tag("pt_BR.UTF-8"), Some(pt)); - assert_eq!(lang_from_tag("pt_PT"), Some(pt)); - assert_eq!(tr(pt, "Arquivo do usuário.pdf"), "Arquivo do usuário.pdf"); - assert_eq!((0..=3).map(|n| (pt.0.plural)(n)).collect::>(), [0, 0, 1, 1]); - let mut app = crate::PdfCraftApp::default(); - app.set_option("language", "pt-br").unwrap(); - assert_eq!(app.language, "pt-br"); - let mut restored = crate::PdfCraftApp::default(); - restored.restore(&app.persist()); - assert_eq!(restored.language, "pt-br"); - } - - #[test] - fn spanish_is_registered() { - let es = Lang::from_code("es").expect("es registered"); - assert_eq!(es.name(), "Español"); - assert_eq!(normalize_pref("ES"), Some("es")); - assert_eq!(lang_from_tag("es_ES.UTF-8"), Some(es)); - assert_eq!(lang_from_tag("es-MX"), Some(es)); - assert_eq!(first_supported("es-ES\r\nen-US"), Some(es)); - assert_eq!(tr(es, "Informe del usuario.pdf"), "Informe del usuario.pdf"); - let mut app = crate::PdfCraftApp::default(); - app.set_option("language", "es").unwrap(); - assert_eq!(app.language, "es"); - let mut restored = crate::PdfCraftApp::default(); - restored.restore(&app.persist()); - assert_eq!(restored.language, "es"); - } - - /// Each sentence the engine words for itself keeps its holes when it is said in Spanish, so - /// the document's own words still have somewhere to go: a translation that drops `{p}` or - /// `{}` would read as a sentence about nothing in particular. - #[test] - fn spanish_keeps_the_holes_in_what_the_engine_says() { - let es = Lang::from_code("es").expect("es registered"); - let lists: [&[&str]; 5] = [ - pdfcraft_engine::a11y::WORDINGS, - pdfcraft_engine::pdfa::WORDINGS, - pdfcraft_engine::sign::pdf::WORDINGS, - pdfcraft_engine::print::WORDINGS, - pdfcraft_engine::field_table::WORDINGS, - ]; - for wording in lists.into_iter().flatten() { - let said = tr(es, wording); - // `{}` is one hole; `{name}` holes are named, and each name must survive by itself. - assert_eq!(wording.matches("{}").count(), said.matches("{}").count(), "the Spanish of {wording:?} loses a hole: {said:?}"); - for hole in wording.split('{').skip(1).filter_map(|rest| rest.split_once('}')).map(|(name, _)| name).filter(|n| !n.is_empty()) { - assert!(said.contains(&format!("{{{hole}}}")), "the Spanish of {wording:?} loses {{{hole}}}: {said:?}"); - } - } - } - - #[test] - fn telugu_is_registered() { - let te = Lang::from_code("te").expect("te registered"); - assert_eq!(te.name(), "తెలుగు"); - assert_eq!(normalize_pref("TE"), Some("te")); - assert_eq!(lang_from_tag("te_IN.UTF-8"), Some(te)); - assert_eq!(first_supported("te-IN\r\nen-US"), Some(te)); - assert_eq!(tr(te, "నివేదిక.pdf"), "నివేదిక.pdf"); - let mut app = crate::PdfCraftApp::default(); - app.set_option("language", "te").unwrap(); - assert_eq!(app.language, "te"); - let mut restored = crate::PdfCraftApp::default(); - restored.restore(&app.persist()); - assert_eq!(restored.language, "te"); - } - - /// Telugu puts the verb last, so it translates "Undo {action}" as a whole phrase; a catalog - /// without that entry (Spanish) keeps the verb in front of the action. - #[test] - fn undo_and_redo_labels_use_the_catalog_phrase_when_there_is_one() { - let te = Lang::from_code("te").expect("te registered"); - set_current(te); - assert_eq!(command_label("Undo"), "రద్దు చేయి"); - assert_eq!(command_label("Undo Untranslated custom action"), "రద్దు చేయి: Untranslated custom action"); - assert_eq!(command_label("Redo Untranslated custom action"), "మళ్లీ చేయి: Untranslated custom action"); - let es = Lang::from_code("es").expect("es registered"); - set_current(es); - assert_eq!(command_label("Undo Untranslated custom action"), format!("{} Untranslated custom action", tr(es, "Undo"))); - set_current(Lang::EN); - assert_eq!(command_label("Undo Untranslated custom action"), "Undo Untranslated custom action"); - } - - /// A label that reads differently by use has its own contextual entry where a language needs - /// one; a language without it falls back to the plain translation. - #[test] - fn contextual_labels_fall_back_to_the_plain_translation() { - let te = Lang::from_code("te").expect("te registered"); - assert_eq!(tr_ctx(te, "signature pad", "Type"), "టైప్ చేయి"); - assert_eq!(tr(te, "Type"), "రకం"); - // A context no catalog defines, so the fallback stays tested as catalogs fill their gaps. - let es = Lang::from_code("es").expect("es registered"); - assert_eq!(tr_ctx(es, "no such context", "Type"), tr(es, "Type")); - } - - /// With a craft-fonts Telugu face, every Telugu translation has glyphs: with all interface - /// faces (desktop) and with the Telugu face as the only craft-fonts face (the web build keeps - /// it; Telugu labels must not need a Japanese or Arabic face). - #[test] - fn telugu_labels_have_glyphs() { - let telugu: Vec = pdfcraft_fonts::ui_telugu_fonts().iter().map(|f| f.name()).collect(); - if telugu.is_empty() { - eprintln!("skipping Telugu glyph checks: build with CRAFT_FONTS_DIR and a Telugu face to run them"); - return; - } - let te = Lang::from_code("te").expect("te registered"); - let (entries, _) = parse_entries(te.0.source, te.0.plural_forms()); - let labels: String = entries.iter().flat_map(|e| e.translation.chars()).chain(te.name().chars()).filter(|c| !c.is_control()).collect(); - let mut telugu_only = crate::theme::font_definitions(); - for family in telugu_only.families.values_mut() { - family.retain(|name| !pdfcraft_fonts::CRAFT_FONTS.iter().any(|face| face.name() == *name) || telugu.contains(name)); - } - use egui::epaint::text::{Fonts, TextOptions}; - for (build, defs) in [("all faces", crate::theme::font_definitions()), ("Telugu face only", telugu_only)] { - let mut fonts = Fonts::new(TextOptions::default(), defs); - for id in [egui::FontId::proportional(13.0), egui::FontId::monospace(13.0), crate::theme::medium(13.0), crate::theme::semibold(17.0)] { - assert!(fonts.has_glyphs(&id, &labels), "{build}: {id:?} lacks a Telugu label glyph"); - } - } - } - - #[test] - fn czech_is_registered_and_persists() { - let cs = Lang::from_code("cs").expect("cs registered"); - assert_eq!(cs.name(), "Čeština"); - assert_eq!(tr(cs, "Žluťoučký kůň.pdf"), "Žluťoučký kůň.pdf"); - assert_eq!((0..=6).map(|n| (cs.0.plural)(n)).collect::>(), [2, 0, 1, 1, 1, 2, 2]); - let mut app = crate::PdfCraftApp::default(); - app.set_option("language", "cs").unwrap(); - assert!(app.set_option("language", "cz").is_err()); - assert_eq!(app.language, "cs"); - let mut restored = crate::PdfCraftApp::default(); - restored.restore(&app.persist()); - assert_eq!(restored.language, "cs"); - } - - #[test] - fn turkish_is_registered_and_persists() { - let tr_lang = Lang::from_code("tr").expect("tr registered"); - assert_eq!(tr_lang.name(), "Türkçe"); - assert_eq!(tr(tr_lang, "Türkçe Belge.pdf"), "Türkçe Belge.pdf"); - assert_eq!((0..=6).map(|n| (tr_lang.0.plural)(n)).collect::>(), [0; 7]); - let mut app = crate::PdfCraftApp::default(); - app.set_option("language", "tr").unwrap(); - assert!(app.set_option("language", "tu").is_err()); - assert_eq!(app.language, "tr"); - let mut restored = crate::PdfCraftApp::default(); - restored.restore(&app.persist()); - assert_eq!(restored.language, "tr"); - } - - /// The code joins some labels with a space at either end, so these catalogs keep exactly the - /// source's, and write … rather than three dots. - #[test] - fn catalog_rows_keep_the_source_spacing_and_ellipsis() { - let edges = |s: &str| (s.len() - s.trim_start().len(), s.len() - s.trim_end().len()); - for code in ["cs", "tr", "vi"] { - let lang = Lang::from_code(code).expect("registered"); - let (entries, errors) = parse_entries(lang.0.source, lang.0.plural_forms()); - assert!(errors.is_empty(), "{code}: {errors:?}"); - for e in &entries { - assert!(!e.translation.contains("..."), "{code}: use … rather than three dots: {:?}", e.translation); - assert_eq!(edges(&e.translation), edges(&e.source), "{code}: stray whitespace: {:?}", e.translation); - } - } - } - - #[test] - fn simplified_chinese_is_registered() { - let zh = Lang::from_code("zh-hans").expect("zh-hans registered"); - assert_eq!(zh.name(), "简体中文"); - assert_eq!(normalize_pref("zh-Hans"), Some("zh-hans")); - assert_eq!(normalize_pref("zh"), None, "only exact codes are preferences"); - assert_eq!(tr(zh, "报告.pdf"), "报告.pdf"); - } - - #[test] - fn traditional_chinese_is_registered() { - let zh = Lang::from_code("zh-hant").expect("zh-hant registered"); - assert_eq!(zh.name(), "繁體中文"); - assert_eq!(normalize_pref("zh-Hant"), Some("zh-hant")); - assert_eq!(tr(zh, "報告.pdf"), "報告.pdf"); - } - - #[test] - fn french_is_registered() { - let fr = Lang::from_code("fr").expect("fr registered"); - assert_eq!(fr.name(), "Français"); - assert_eq!(normalize_pref("FR"), Some("fr")); - assert_eq!(lang_from_tag("fr_FR.UTF-8"), Some(fr)); - assert_eq!(lang_from_tag("fr-CA"), Some(fr)); - assert_eq!(first_supported("fr-FR\r\nen-US"), Some(fr)); - assert_eq!(tr(fr, "Rapport de l'utilisateur.pdf"), "Rapport de l'utilisateur.pdf"); - assert_eq!((0..=3).map(|n| (fr.0.plural)(n)).collect::>(), [0, 0, 1, 1]); - let mut app = crate::PdfCraftApp::default(); - app.set_option("language", "fr").unwrap(); - assert_eq!(app.language, "fr"); - let mut restored = crate::PdfCraftApp::default(); - restored.restore(&app.persist()); - assert_eq!(restored.language, "fr"); - } - - #[test] - fn german_is_registered() { - let de = Lang::from_code("de").expect("de registered"); - assert_eq!(de.name(), "Deutsch"); - assert_eq!(normalize_pref("DE"), Some("de")); - assert_eq!(lang_from_tag("de_DE.UTF-8"), Some(de)); - assert_eq!(lang_from_tag("de-AT"), Some(de)); - assert_eq!(lang_from_tag("de_CH"), Some(de)); - assert_eq!(first_supported("de-DE\r\nen-US"), Some(de)); - assert_eq!(tr(de, "Bericht des Nutzers.pdf"), "Bericht des Nutzers.pdf"); - assert_eq!((0..=3).map(|n| (de.0.plural)(n)).collect::>(), [1, 0, 1, 1]); - let mut app = crate::PdfCraftApp::default(); - app.set_option("language", "de").unwrap(); - assert_eq!(app.language, "de"); - let mut restored = crate::PdfCraftApp::default(); - restored.restore(&app.persist()); - assert_eq!(restored.language, "de"); - } - - #[test] - fn tool_names_inside_sentences_are_lowercased_except_in_german() { - set_current(Lang::EN); - assert_eq!(in_sentence("Text Box"), "text box"); - set_current(Lang::from_code("de").expect("de registered")); - assert_eq!(in_sentence("Textfeld"), "Textfeld"); - set_current(Lang::EN); - } - - #[test] - fn turkish_keeps_its_dotted_and_dotless_i_when_changing_case() { - set_current(Lang::from_code("tr").expect("tr registered")); - assert_eq!(upper_label("Advanced"), "GELİŞMİŞ"); - assert_eq!(upper_label("Signatures"), "İMZALAR"); - // A label with no Turkish row is still English and keeps the English rule. - assert_eq!(upper_label("Untranslated title"), "UNTRANSLATED TITLE"); - assert_eq!(in_sentence("İmza Alanı"), "imza alanı"); - assert_eq!(in_sentence("Işık"), "ışık"); - set_current(Lang::EN); - assert_eq!(upper_label("Advanced"), "ADVANCED"); - assert_eq!(in_sentence("Image"), "image"); - } - - #[test] - fn russian_is_registered() { - let ru = Lang::from_code("ru").expect("ru registered"); - assert_eq!(ru.name(), "Русский"); - assert_eq!(normalize_pref("RU"), Some("ru")); - assert_eq!(lang_from_tag("ru_RU.UTF-8"), Some(ru)); - assert_eq!(lang_from_tag("ru-BY"), Some(ru)); - assert_eq!(first_supported("ru-RU\r\nen-US"), Some(ru)); - assert_eq!(tr(ru, "Rapport de l'utilisateur.pdf"), "Rapport de l'utilisateur.pdf"); - // one (1), few (2-4), many (0, 5+), with 11-14 exception - assert_eq!((0..=3).map(|n| (ru.0.plural)(n)).collect::>(), [2, 0, 1, 1]); - let mut app = crate::PdfCraftApp::default(); - app.set_option("language", "ru").unwrap(); - assert_eq!(app.language, "ru"); - let mut restored = crate::PdfCraftApp::default(); - restored.restore(&app.persist()); - assert_eq!(restored.language, "ru"); - } - - #[test] - fn finnish_is_registered_and_persists() { - let fi = Lang::from_code("fi").expect("fi registered"); - assert_eq!(fi.name(), "Suomi"); - assert_eq!(normalize_pref("FI"), Some("fi")); - for tag in ["fi", "fi-FI", "fi_FI.UTF-8", "FI-fi"] { - assert_eq!(lang_from_tag(tag), Some(fi), "{tag}"); - } - // Filipino (`fil`) and Swedish in Finland (`sv-FI`) don't pick Finnish. - assert_ne!(lang_from_tag("fil"), Some(fi)); - assert_ne!(lang_from_tag("sv-FI"), Some(fi)); - let mut app = crate::PdfCraftApp::default(); - app.set_option("language", "FI").unwrap(); - assert_eq!(app.language, "fi"); - let mut restored = crate::PdfCraftApp::default(); - restored.restore(&app.persist()); - assert_eq!(restored.language, "fi"); - } - - #[test] - fn lithuanian_is_registered_and_persists() { - let lt = Lang::from_code("lt").expect("lt registered"); - assert_eq!(lt.name(), "Lietuvių"); - assert_eq!(normalize_pref("LT"), Some("lt")); - for tag in ["lt", "lt-LT", "lt_LT.UTF-8", "LT-lt"] { - assert_eq!(lang_from_tag(tag), Some(lt), "{tag}"); - } - let mut app = crate::PdfCraftApp::default(); - app.set_option("language", "LT").unwrap(); - assert_eq!(app.language, "lt"); - let mut restored = crate::PdfCraftApp::default(); - restored.restore(&app.persist()); - assert_eq!(restored.language, "lt"); - assert!(restored.set_option("language", "xx").is_err()); - } - - #[test] - fn norwegian_bokmal_is_registered_and_persists() { - let nb = Lang::from_code("nb").expect("nb registered"); - assert_eq!(nb.name(), "Norsk bokmål"); - assert_eq!(normalize_pref("NB"), Some("nb")); - assert_eq!(normalize_pref("no"), None, "only exact codes are preferences"); - // Current systems say `nb`; older ones and some Linux setups say `no`. Nynorsk is `nn`. - for tag in ["nb", "nb-NO", "nb_NO.UTF-8", "no", "no_NO.UTF-8", "NO-no", "nn", "nn_NO.UTF-8"] { - assert_eq!(lang_from_tag(tag), Some(nb), "{tag}"); - } - assert_eq!(first_supported("(\n \"nb-NO\",\n \"en-US\"\n)\n"), Some(nb)); - assert_eq!(first_supported("nn-NO\r\nen-US\r\n"), Some(nb)); - assert_eq!((0..=3).map(|n| (nb.0.plural)(n)).collect::>(), [1, 0, 1, 1]); - let mut app = crate::PdfCraftApp::default(); - app.set_option("language", "NB").unwrap(); - assert_eq!(app.language, "nb"); - let mut restored = crate::PdfCraftApp::default(); - restored.restore(&app.persist()); - assert_eq!(restored.language, "nb"); - } - - #[test] - fn vietnamese_is_registered_and_persists() { - let vi = Lang::from_code("vi").expect("vi registered"); - assert_eq!(vi.name(), "Tiếng Việt"); - assert_eq!(normalize_pref("Vi"), Some("vi")); - for tag in ["vi", "vi-VN", "vi_VN.UTF-8", "VI-vn"] { - assert_eq!(lang_from_tag(tag), Some(vi), "{tag}"); - } - assert_eq!(first_supported("vi-VN\r\nen-US"), Some(vi)); - assert_eq!((0..=3).map(|n| (vi.0.plural)(n)).collect::>(), [0, 0, 0, 0]); - let mut app = crate::PdfCraftApp::default(); - app.set_option("language", "vi").unwrap(); - assert_eq!(app.language, "vi"); - let mut restored = crate::PdfCraftApp::default(); - restored.restore(&app.persist()); - assert_eq!(restored.language, "vi"); - assert!(restored.set_option("language", "xx").is_err()); - assert_eq!(restored.language, "vi"); - } - - #[test] - fn romanian_is_registered_with_three_plural_forms() { - let ro = Lang::from_code("ro").expect("ro registered"); - for tag in ["ro", "ro-RO", "ro_RO.UTF-8", "RO-ro", "ro-MD"] { - assert_eq!(lang_from_tag(tag), Some(ro), "{tag}"); - } - assert_eq!(normalize_pref("Ro"), Some("ro")); - let info = LANGUAGES.iter().find(|language| language.code == "ro").expect("ro row"); - assert_eq!(info.plural_forms(), 3); - // one: 1; few: 0 and n % 100 in 1..=19 ("2 pagini", "101 pagini"); other: the "de" form ("20 de pagini"). - for (n, form) in [(1, 0), (0, 1), (2, 1), (19, 1), (101, 1), (119, 1), (20, 2), (100, 2), (120, 2), (1000, 2)] { - assert_eq!(plural_ro(n), form, "{n}"); - } - } - - #[test] - fn catalan_is_registered_and_persists() { - let ca = Lang::from_code("ca").expect("ca registered"); - assert_eq!(ca.name(), "Català"); - assert_eq!(normalize_pref("CA"), Some("ca")); - for tag in ["ca", "ca-ES", "ca_ES.UTF-8", "CA-es", "ca_ES@valencia"] { - assert_eq!(lang_from_tag(tag), Some(ca), "{tag}"); - } - assert_eq!(first_supported("ca-ES\r\nen-US"), Some(ca)); - // one (1), other (0, 2+), as in English - assert_eq!((0..=3).map(|n| (ca.0.plural)(n)).collect::>(), [1, 0, 1, 1]); - let mut app = crate::PdfCraftApp::default(); - app.set_option("language", "CA").unwrap(); - assert_eq!(app.language, "ca"); - let mut restored = crate::PdfCraftApp::default(); - restored.restore(&app.persist()); - assert_eq!(restored.language, "ca"); - assert!(restored.set_option("language", "xx").is_err()); - assert_eq!(restored.language, "ca"); - } - - #[test] - fn slovene_is_registered_and_persists() { - let sl = Lang::from_code("sl").expect("sl registered"); - assert_eq!(sl.name(), "Slovenščina"); - assert_eq!(normalize_pref("SL"), Some("sl")); - for tag in ["sl", "sl-SI", "sl_SI.UTF-8", "SL-si", "sl_SI@variant"] { - assert_eq!(lang_from_tag(tag), Some(sl), "{tag}"); - } - assert_eq!(first_supported("sl-SI\r\nen-US"), Some(sl)); - assert_eq!(tr(sl, "Poročilo {n}.pdf"), "Poročilo {n}.pdf"); - // one (1), two (2), few (3, 4), other (0, 5+) - assert_eq!((0..=5).map(|n| (sl.0.plural)(n)).collect::>(), [3, 0, 1, 2, 2, 3]); - let mut app = crate::PdfCraftApp::default(); - app.set_option("language", "SL").unwrap(); - assert_eq!(app.language, "sl"); - let mut restored = crate::PdfCraftApp::default(); - restored.restore(&app.persist()); - assert_eq!(restored.language, "sl"); - assert!(restored.set_option("language", "xx").is_err()); - assert_eq!(restored.language, "sl"); - } - - #[test] - fn albanian_is_registered_and_persists() { - let sq = Lang::from_code("sq").expect("sq registered"); - assert_eq!(sq.name(), "Shqip"); - assert_eq!(normalize_pref("SQ"), Some("sq")); - for tag in ["sq", "sq-AL", "sq_AL.UTF-8", "SQ-xk", "sq_XK@variant"] { - assert_eq!(lang_from_tag(tag), Some(sq), "{tag}"); - } - assert_eq!(first_supported("sq-AL\r\nen-US"), Some(sq)); - // one (1), other (0, 2+) - assert_eq!((0..=3).map(|n| (sq.0.plural)(n)).collect::>(), [1, 0, 1, 1]); - let mut app = crate::PdfCraftApp::default(); - app.set_option("language", "SQ").unwrap(); - assert_eq!(app.language, "sq"); - let mut restored = crate::PdfCraftApp::default(); - restored.restore(&app.persist()); - assert_eq!(restored.language, "sq"); - assert!(restored.set_option("language", "xx").is_err()); - assert_eq!(restored.language, "sq"); - } - - #[test] - fn slovene_plurals_cover_dual_few_teens_and_large_counts() { - let sl = Lang::from_code("sl").expect("sl registered"); - assert_eq!(sl.0.plural_forms(), 4); - for (counts, form) in [ - (vec![1, 101, 201, 1001, 1101], 0), - (vec![2, 102, 202, 1002], 1), - (vec![3, 4, 103, 104, 1003], 2), - (vec![0, 5, 10, 11, 12, 13, 14, 15, 20, 21, 22, 23, 24, 25, 31, 100, 111, 112, 121, 122, 1000, u64::MAX], 3), - ] { - for n in counts { - assert_eq!((sl.0.plural)(n), form, "{n}"); - } - } - } - - #[test] - fn indonesian_and_vietnamese_are_registered_and_persist() { - let id = Lang::from_code("id").expect("id registered"); - let vi = Lang::from_code("vi").expect("vi registered"); - assert_eq!(id.name(), "Bahasa Indonesia"); - assert_eq!(vi.name(), "Tiếng Việt"); - assert_eq!(normalize_pref("ID"), Some("id")); - assert_eq!(normalize_pref("Vi"), Some("vi")); - for tag in ["id", "id-ID", "id_ID.UTF-8", "ID-id"] { - assert_eq!(lang_from_tag(tag), Some(id), "{tag}"); - } - for tag in ["vi", "vi-VN", "vi_VN.UTF-8", "VI-vn"] { - assert_eq!(lang_from_tag(tag), Some(vi), "{tag}"); - } - assert_eq!(first_supported("id-ID\r\nen-US"), Some(id)); - assert_eq!(first_supported("vi-VN\r\nen-US"), Some(vi)); - // One CLDR plural form (CLDR `id`, `vi`): the message reads the same for every count. - assert_eq!((0..=3).map(|n| (id.0.plural)(n)).collect::>(), [0, 0, 0, 0]); - assert_eq!((0..=3).map(|n| (vi.0.plural)(n)).collect::>(), [0, 0, 0, 0]); - let mut app = crate::PdfCraftApp::default(); - app.set_option("language", "ID").unwrap(); - assert_eq!(app.language, "id"); - let mut restored = crate::PdfCraftApp::default(); - restored.restore(&app.persist()); - assert_eq!(restored.language, "id"); - let mut app = crate::PdfCraftApp::default(); - app.set_option("language", "vi").unwrap(); - assert_eq!(app.language, "vi"); - let mut restored = crate::PdfCraftApp::default(); - restored.restore(&app.persist()); - assert_eq!(restored.language, "vi"); - assert!(restored.set_option("language", "xx").is_err()); - assert_eq!(restored.language, "vi"); - } - - #[test] - fn hindi_and_korean_are_registered_and_persist() { - let hi = Lang::from_code("hi").expect("hi registered"); - let ko = Lang::from_code("ko").expect("ko registered"); - assert_eq!(hi.name(), "हिन्दी"); - assert_eq!(ko.name(), "한국어"); - assert_eq!(normalize_pref("HI"), Some("hi")); - assert_eq!(normalize_pref("Ko"), Some("ko")); - for tag in ["hi", "hi-IN", "hi_IN.UTF-8", "HI-in"] { - assert_eq!(lang_from_tag(tag), Some(hi), "{tag}"); - } - for tag in ["ko", "ko-KR", "ko_KR.UTF-8", "KO-kr"] { - assert_eq!(lang_from_tag(tag), Some(ko), "{tag}"); - } - assert_eq!(first_supported("hi-IN\r\nen-US"), Some(hi)); - assert_eq!(first_supported("ko-KR\r\nen-US"), Some(ko)); - // One plural form per catalog: the message reads the same for every count. - assert_eq!((0..=3).map(|n| (hi.0.plural)(n)).collect::>(), [0, 0, 0, 0]); - assert_eq!((0..=3).map(|n| (ko.0.plural)(n)).collect::>(), [0, 0, 0, 0]); - for code in ["hi", "ko"] { - let mut app = crate::PdfCraftApp::default(); - app.set_option("language", &code.to_uppercase()).unwrap(); - assert_eq!(app.language, code); - let mut restored = crate::PdfCraftApp::default(); - restored.restore(&app.persist()); - assert_eq!(restored.language, code); - assert!(restored.set_option("language", "xx").is_err()); - assert_eq!(restored.language, code); - } - } - - #[test] - fn bulgarian_is_registered() { - let bg = Lang::from_code("bg").expect("bg registered"); - assert_eq!(bg.name(), "Български"); - assert_eq!(normalize_pref("BG"), Some("bg")); - assert_eq!(lang_from_tag("bg_BG.UTF-8"), Some(bg)); - assert_eq!(lang_from_tag("bg-BG"), Some(bg)); - assert_eq!(first_supported("bg-BG\r\nen-US"), Some(bg)); - assert_eq!(tr(bg, "Rapport de l'utilisateur.pdf"), "Rapport de l'utilisateur.pdf"); - // one (1), other (0, 2+) - assert_eq!((0..=3).map(|n| (bg.0.plural)(n)).collect::>(), [1, 0, 1, 1]); - let mut app = crate::PdfCraftApp::default(); - app.set_option("language", "bg").unwrap(); - assert_eq!(app.language, "bg"); - let mut restored = crate::PdfCraftApp::default(); - restored.restore(&app.persist()); - assert_eq!(restored.language, "bg"); - } - - #[test] - fn hungarian_is_registered() { - let hu = Lang::from_code("hu").expect("hu registered"); - assert_eq!(hu.name(), "Magyar"); - assert_eq!(normalize_pref("HU"), Some("hu")); - assert_eq!(lang_from_tag("hu_HU.UTF-8"), Some(hu)); - assert_eq!(lang_from_tag("hu-HU"), Some(hu)); - assert_eq!(first_supported("hu-HU\r\nen-US"), Some(hu)); - assert_eq!(tr(hu, "Rapport de l'utilisateur.pdf"), "Rapport de l'utilisateur.pdf"); - // one (1), other (0, 2+) - assert_eq!((0..=3).map(|n| (hu.0.plural)(n)).collect::>(), [1, 0, 1, 1]); - let mut app = crate::PdfCraftApp::default(); - app.set_option("language", "hu").unwrap(); - assert_eq!(app.language, "hu"); - let mut restored = crate::PdfCraftApp::default(); - restored.restore(&app.persist()); - assert_eq!(restored.language, "hu"); - } - - #[test] - fn ukrainian_is_registered_and_persists() { - let uk = Lang::from_code("uk").expect("uk registered"); - assert_eq!(uk.name(), "Українська"); - assert_eq!(normalize_pref("UK"), Some("uk")); - for tag in ["uk", "uk-UA", "uk_UA.UTF-8", "UK-ua", "uk_UA@variant"] { - assert_eq!(lang_from_tag(tag), Some(uk), "{tag}"); - } - assert_eq!(first_supported("uk-UA\r\nen-US"), Some(uk)); - assert_eq!(first_supported("(\n \"uk-UA\",\n \"en-US\"\n)\n"), Some(uk)); - assert_eq!(tr(uk, "Звіт {n}.pdf"), "Звіт {n}.pdf"); - let mut app = crate::PdfCraftApp::default(); - app.set_option("language", "UK").unwrap(); - assert_eq!(app.language, "uk"); - let mut restored = crate::PdfCraftApp::default(); - restored.restore(&app.persist()); - assert_eq!(restored.language, "uk"); - assert!(restored.set_option("language", "xx").is_err()); - assert_eq!(restored.language, "uk"); - } - - #[test] - fn ukrainian_integer_plurals_cover_teens_and_large_counts() { - let uk = Lang::from_code("uk").expect("uk registered"); - assert_eq!(uk.0.plural_forms(), 3); - for (counts, form) in [ - (vec![1, 21, 31, 101, 121, 1001], 0), - (vec![2, 3, 4, 22, 23, 24, 102, 104, 122, 1002], 1), - (vec![0, 5, 10, 11, 12, 13, 14, 19, 20, 25, 100, 110, 111, 112, 114, 1000, u64::MAX], 2), - ] { - for n in counts { - assert_eq!((uk.0.plural)(n), form, "{n}"); - } - } - } - - fn arabic() -> Lang { - Lang::from_code("ar").expect("ar registered") - } - - #[test] - fn arabic_is_registered_and_persists() { - let ar = arabic(); - assert_eq!(ar.name(), "العربية"); - assert!(ar.rtl()); - let rtl: Vec<&str> = Lang::all().filter(|l| l.rtl()).map(|l| l.code()).collect(); - assert_eq!(rtl, ["ar", "ckb", "fa"], "Arabic, Kurdish and Persian are the right-to-left languages"); - assert_eq!(normalize_pref("AR"), Some("ar")); - for tag in ["ar", "ar-MA", "ar_EG.UTF-8", "AR-sa", "ar_DZ@variant"] { - assert_eq!(lang_from_tag(tag), Some(ar), "{tag}"); - } - assert_eq!(first_supported("ar-MA\r\nen-US"), Some(ar)); - // Unknown strings and user text are never reordered by a lookup. - assert_eq!(tr(ar, "تقرير نهائي {n}.pdf"), "تقرير نهائي {n}.pdf"); - let mut app = crate::PdfCraftApp::default(); - app.set_option("language", "AR").unwrap(); - assert_eq!(app.language, "ar"); - let mut restored = crate::PdfCraftApp::default(); - restored.restore(&app.persist()); - assert_eq!(restored.language, "ar"); - assert!(restored.set_option("language", "xx").is_err()); - assert_eq!(restored.language, "ar"); - } - - /// Catalog lookups stay in logical order; the egui 0.37 fork owns bidi shaping and display - /// reordering (E5/E12), so preordering here would reverse Arabic twice. Accessibility names, - /// copied labels and search therefore see the source string. - #[test] - fn arabic_catalog_stays_logical() { - let ar = arabic(); - assert_eq!(tr(ar, "Save as…"), "حفظ باسم…"); - assert_eq!(tr(ar, "About PdfCraft"), "حول PdfCraft"); - assert_eq!(tr(ar, "Name:"), "الاسم:"); - assert_eq!(tr(ar, "Issued by: "), "صادرة عن: "); - // Placeholders survive in their logical positions. - assert_eq!(tr(ar, "Couldn't open {name}: {e}"), "تعذّر فتح {name}: {e}"); - assert_eq!(fmt(tr(ar, "Couldn't open {name}: {e}"), &[("name", "a {e}.pdf"), ("e", "x")]), "تعذّر فتح a {e}.pdf: x"); - assert_eq!(fmt(tr(ar, "Page {p} of {n}"), &[("p", "3"), ("n", "12")]), "الصفحة 3 من 12"); - set_current(ar); - let command = command_label("Undo Insert pages from a.pdf"); - assert!(command.contains("a.pdf") && command.contains("إدراج صفحات"), "logical command label: {command:?}"); - assert_eq!(menu_label("file.saveAs", "Save as…"), "حفظ باسم…"); - set_current(Lang::EN); - } - - #[test] - fn arabic_plurals_follow_the_six_cldr_forms() { - let ar = arabic(); - assert_eq!(ar.0.plural_forms(), 6); - let form = |n| (ar.0.plural)(n); - assert_eq!([0, 1, 2].map(form), [0, 1, 2]); - assert_eq!([3, 10, 103, 110, 1003].map(form), [3; 5]); - assert_eq!([11, 99, 111, 199, 1011].map(form), [4; 5]); - assert_eq!([100, 101, 102, 200, 1000, u64::MAX - 13].map(form), [5; 6]); - } - - fn persian() -> Lang { - Lang::from_code("fa").expect("fa registered") - } - - #[test] - fn persian_is_registered_and_persists() { - let fa = persian(); - assert_eq!(fa.name(), "فارسی"); - assert!(fa.rtl()); - // CLDR `fa` (one: i = 0 or n = 1): 0 and 1 are singular, every other count the plural. - assert_eq!(fa.0.plural_forms(), 2); - assert_eq!([0, 1, 2, 11].map(|n| (fa.0.plural)(n)), [0, 0, 1, 1]); - assert_eq!(normalize_pref("FA"), Some("fa")); - for tag in ["fa", "fa-IR", "fa_IR.UTF-8", "FA-af", "fa_IR@calendar=persian"] { - assert_eq!(lang_from_tag(tag), Some(fa), "{tag}"); - } - assert_eq!(first_supported("fa-IR\r\nen-US"), Some(fa)); - // Lookups return Persian in logical order; epaint handles visual ordering. - assert_eq!(tr(fa, "Save"), "ذخیره"); - assert_eq!(tr(fa, "Cancel"), "لغو"); - assert_eq!(tr(fa, "File"), "فایل"); - // Unknown strings and user text are never reordered by a lookup. - assert_eq!(tr(fa, "گزارش نهایی {n}.pdf"), "گزارش نهایی {n}.pdf"); - let mut app = crate::PdfCraftApp::default(); - app.set_option("language", "FA").unwrap(); - assert_eq!(app.language, "fa"); - let mut restored = crate::PdfCraftApp::default(); - restored.restore(&app.persist()); - assert_eq!(restored.language, "fa"); - assert!(restored.set_option("language", "xx").is_err()); - assert_eq!(restored.language, "fa"); - } - - /// With a craft-fonts Arabic face, every Arabic translation has glyphs: with all interface - /// faces (desktop) and with the Arabic face as the only craft-fonts face (the web build keeps - /// it; Arabic labels must not need a Japanese face). - #[test] - fn arabic_labels_have_glyphs() { - let faces: Vec = pdfcraft_fonts::ui_arabic_fonts().iter().map(|f| f.name()).collect(); - if faces.is_empty() { - eprintln!("skipping Arabic glyph checks: build with CRAFT_FONTS_DIR and an Arabic face to run them"); - return; - } - let ar = arabic(); - let (entries, _) = parse_entries(ar.0.source, ar.0.plural_forms()); - let labels: String = entries.iter().flat_map(|e| e.translation.chars()).chain(ar.name().chars()).filter(|c| !c.is_control()).collect(); - let mut arabic_only = crate::theme::font_definitions(); - for family in arabic_only.families.values_mut() { - family.retain(|name| !pdfcraft_fonts::CRAFT_FONTS.iter().any(|face| face.name() == *name) || faces.contains(name)); - } - use egui::epaint::text::{Fonts, TextOptions}; - for (build, defs) in [("all faces", crate::theme::font_definitions()), ("Arabic face only", arabic_only)] { - let mut fonts = Fonts::new(TextOptions::default(), defs); - for id in [egui::FontId::proportional(13.0), egui::FontId::monospace(13.0), crate::theme::medium(13.0), crate::theme::semibold(17.0)] { - let missing: std::collections::BTreeSet = labels.chars().filter(|c| !fonts.has_glyphs(&id, &c.to_string())).collect(); - assert!(missing.is_empty(), "{build}: {id:?} lacks Arabic label glyphs {missing:?}"); - } - } - } - - /// Every bundled catalog is well-formed and consistent with its sources. - #[test] - fn bundled_catalogs_are_consistent() { - let mut codes = std::collections::HashSet::new(); - for l in &LANGUAGES { - assert!(codes.insert(l.code), "duplicate code {}", l.code); - assert!(l.code == l.code.to_ascii_lowercase() && !l.name.is_empty(), "{}", l.code); - let (entries, errors) = parse_entries(l.source, l.plural_forms()); - assert!(errors.is_empty(), "{}: {errors:?}", l.code); - for e in entries.iter().filter(|e| e.context == "@id") { - let command = pdfcraft_engine::commands::command(&e.source); - let Some(command) = command else { panic!("{}: unknown command id {:?}", l.code, e.source) }; - assert_eq!(placeholders(&e.translation), placeholders(command.label), "{}: {}", l.code, e.source); - assert_eq!(e.translation.ends_with('…'), command.label.ends_with('…'), "{}: ellipsis mismatch: {}", l.code, e.source); - } - } - } - - /// The translation gaps report (`cargo xtask i18n-report`, a step of `cargo xtask ci`): for - /// each language, how many English strings it has no translation for, and the first few. - /// It never fails: a missing string shows in English. - /// - /// The English strings are the UI source's literals, the commands, All tools, the engine's - /// own wordings, and every key of every bundled catalog (which covers contextual, plural and - /// dynamically looked-up strings the scan can't see). - #[test] - fn translation_gaps_report() { - use std::collections::BTreeSet; - const SHOWN: usize = 3; - let mut plain: BTreeSet = ui_literals(); - let mut add = |s: &str| { - plain.insert(s.to_string()); - }; - for command in pdfcraft_engine::commands::COMMANDS { - add(command.label); - command.menu.into_iter().for_each(&mut add); - } - for group in pdfcraft_engine::catalog::TOOL_GROUPS { - add(group.label); - for section in group.sections { - add(section.title); - section.items.iter().for_each(|item| add(item.label)); - } - } - pdfcraft_engine::catalog::TOOL_CATEGORIES.iter().for_each(|category| add(category.label)); - let engine: [&[&str]; 9] = [ - pdfcraft_engine::a11y::WORDINGS, - pdfcraft_engine::a11y::REPORT_WORDS, - pdfcraft_engine::pdfa::WORDINGS, - pdfcraft_engine::sign::pdf::WORDINGS, - pdfcraft_engine::sign::pdf::kind::ALL, - pdfcraft_engine::print::WORDINGS, - pdfcraft_engine::EDIT_LABELS, - pdfcraft_engine::BATCH_LABELS, - pdfcraft_engine::field_table::WORDINGS, - ]; - engine.into_iter().flatten().for_each(|s| add(s)); - pdfcraft_engine::sign::AppearanceLabels::default().fields().into_iter().for_each(|(_, label)| add(label)); - crate::credits::NameMode::ALL.iter().for_each(|mode| add(mode.label())); - for key in crate::credits::SortKey::ALL { - let (label, header) = key.label(); - add(label); - add(header); - } - crate::credits::MODEL_COLUMNS.iter().for_each(|s| add(s)); - // (context, source) keys; `@id` rows only reword a command label, which is in `plain`. - let mut keys: BTreeSet<(String, String)> = plain.into_iter().map(|s| (String::new(), s)).collect(); - for language in &LANGUAGES { - let (entries, _) = parse_entries(language.source, language.plural_forms()); - keys.extend(entries.into_iter().filter(|e| e.context != "@id").map(|e| (e.context, e.source))); - } - let mut report = format!("Translation gaps: {} English strings\n", keys.len()); - for lang in Lang::all().filter(|l| *l != Lang::EN) { - let catalog = lang.catalog(); - let missing: Vec<&(String, String)> = keys - .iter() - .filter(|(context, source)| match context.as_str() { - "" => catalog.plain(source).is_none(), - "@plural" => source.split_once('|').is_none_or(|(one, other)| catalog.plural(one, other, 0).is_none()), - // A contextual lookup falls back to the plain translation. - context => catalog.contextual(context, source).is_none() && catalog.plain(source).is_none(), - }) - .collect(); - let mut line = format!(" {:<8} {:>5} missing", lang.code(), missing.len()); - for (i, (context, source)) in missing.iter().take(SHOWN).enumerate() { - let mut shown: String = source.chars().take(48).collect(); - if shown.len() < source.len() { - shown.push('…'); - } - let key = if context.is_empty() { format!("{shown:?}") } else { format!("{context} / {shown:?}") }; - line.push_str(if i == 0 { ": " } else { ", " }); - line.push_str(&key); - } - if missing.len() > SHOWN { - line.push_str(", …"); - } - report.push_str(&line); - report.push('\n'); - } - println!("{REPORT_START}\n{report}{REPORT_END}"); - } - - /// The lines `cargo xtask i18n-report` prints from the test output lie between these. - const REPORT_START: &str = "=== translation gaps ==="; - const REPORT_END: &str = "=== end of translation gaps ==="; - - /// Preferences ▸ Date format ▸ Language offers exactly the interface languages, in order. - #[test] - fn date_languages_are_the_interface_languages() { - let interface: Vec<(&str, &str)> = Lang::all().map(|l| (l.code(), l.name())).collect(); - let dates: Vec<(&str, &str)> = pdfcraft_engine::dates::DATE_LANGUAGES.iter().map(|l| (l.code, l.name)).collect(); - assert_eq!(dates, interface); - } -} +mod tests; #[cfg(test)] mod picker_order_tests { diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs new file mode 100644 index 000000000..b66c31d9c --- /dev/null +++ b/crates/ui-egui/src/i18n/tests.rs @@ -0,0 +1,1227 @@ +use super::catalog::{Entry, parse_entries, placeholders}; +use super::*; + +const JA: fn() -> Lang = || Lang::from_code("ja").expect("ja registered"); + +#[test] +fn keyboard_key_names_have_a_separate_context_and_english_fallback() { + static KEYS: LangInfo = LangInfo { + code: "keys", + name: "Key name test catalog", + source: "key\tDelete\tEntf\n\tDelete\tLöschen\n\tOpen\tÖffnen\n", + plural: plural_one_other, + rtl: false, + catalog: OnceLock::new(), + }; + let previous = current(); + set_current(Lang(&KEYS)); + assert_eq!(key_name("Delete"), "Entf"); + assert_eq!(t("Delete"), "Löschen"); + assert_eq!(key_name("Open"), "Open", "a missing key name must not borrow a command translation"); + set_current(Lang::EN); + assert_eq!(key_name("Delete"), "Delete"); + set_current(previous); +} + +/// Regression test: a language picker uses its native name only when a face can draw it, otherwise its +/// English name. +#[test] +fn picker_names_fall_back_to_english_when_their_script_has_no_face() { + let hi = Lang::from_code("hi").expect("hi registered"); + assert_eq!(hi.picker_name(|_| true), "हिन्दी", "the native name is kept when it can be drawn"); + assert_eq!(hi.picker_name(|_| false), "Hindi", "no Devanagari face: an English name, not an unrenderable label"); + assert_eq!(Lang::EN.picker_name(|_| false), "English"); + for language in Lang::all() { + assert!(!language.english_name().is_empty(), "{}", language.code()); + assert_ne!(language.english_name(), language.code(), "{} needs an English picker fallback", language.code()); + } +} + +#[test] +fn tags_map_to_languages() { + assert_eq!(lang_from_tag("ja_JP.UTF-8"), Some(JA())); + assert_eq!(lang_from_tag("ja-JP"), Some(JA())); + assert_eq!(lang_from_tag("en_US.UTF-8"), Some(Lang::EN)); + assert_eq!(lang_from_tag("C"), Some(Lang::EN)); + assert_eq!(lang_from_tag("POSIX"), Some(Lang::EN)); + assert_eq!(lang_from_tag("fr_FR"), Lang::from_code("fr")); + assert_eq!(lang_from_tag("fr_CA.UTF-8"), Lang::from_code("fr")); + assert_eq!(lang_from_tag("fr-BE"), Lang::from_code("fr")); + assert_eq!(lang_from_tag("zh-TW"), Lang::from_code("zh-hant")); + assert_eq!(lang_from_tag("zh_CN.UTF-8"), Lang::from_code("zh-hans")); + assert_eq!(lang_from_tag("zh"), Lang::from_code("zh-hans")); + assert_eq!(lang_from_tag("zh_HK.UTF-8"), Lang::from_code("zh-hant")); + assert_eq!(lang_from_tag("zh-Hant-MO"), Lang::from_code("zh-hant")); + assert_eq!(lang_from_tag("lo_LA.UTF-8"), Lang::from_code("lo")); + assert_eq!(lang_from_tag("lo-LA"), Lang::from_code("lo")); + assert_eq!(lang_from_tag("lo"), Lang::from_code("lo")); + assert_eq!(lang_from_tag(""), None); + assert_eq!(lang_from_tag("_"), None); +} + +#[test] +fn candidates_walk_from_specific_to_general() { + assert_eq!(candidates("pt_BR.UTF-8"), ["pt-br", "pt"]); + assert_eq!(candidates("pt_PT.UTF-8"), ["pt-pt", "pt-br", "pt"]); + assert_eq!(candidates("pt"), ["pt-br", "pt"]); + assert_eq!(candidates("zh_TW"), ["zh-tw", "zh-hant", "zh"]); + assert_eq!(candidates("zh-CN"), ["zh-cn", "zh-hans", "zh"]); + assert_eq!(candidates("zh-Hant-HK"), ["zh-hant-hk", "zh-hant", "zh"]); + assert_eq!(candidates("lo_LA.UTF-8"), ["lo-la", "lo"]); + assert_eq!(candidates("nb_NO.UTF-8"), ["nb-no", "nb"]); + assert_eq!(candidates("no_NO.UTF-8"), ["no-no", "no", "nb"]); + assert_eq!(candidates("nn-NO"), ["nn-no", "nn", "nb"]); +} + +#[test] +fn macos_language_list_is_parsed() { + assert_eq!(first_supported("(\n \"ja-JP\",\n \"en-US\"\n)\n"), Some(JA())); + assert_eq!(first_supported("(\n \"fr-FR\",\n \"en-US\"\n)\n"), Lang::from_code("fr")); + assert_eq!(first_supported("("), None); +} + +/// #2954: the web build's Auto language follows the browser's preferred languages in order, +/// skipping ones without a catalog, and is English only when none is supported. +#[test] +fn browser_language_list_is_followed_in_order() { + assert_eq!(first_supported_tag(["it-IT", "en-US"]), Lang::from_code("it")); + assert_eq!(first_supported_tag(["xx-YY", "ja", "fr-FR"]), Some(JA())); + assert_eq!(first_supported_tag(["zh-TW"]), Lang::from_code("zh-hant")); + assert_eq!(first_supported_tag(["en-GB", "fr-FR"]), Some(Lang::EN)); + assert_eq!(first_supported_tag([" pt-PT "]), Lang::from_code("pt-br")); + assert_eq!(first_supported_tag(["", "xx"]), None); + assert_eq!(first_supported_tag(Vec::::new()), None); + let long = std::iter::repeat_n("xx", 10_000).chain(["fr"]); + assert_eq!(first_supported_tag(long), None, "only the first tags are read"); +} + +#[test] +fn windows_display_language_resolves_chinese_scripts() { + for tag in ["zh-CN", "zh-SG", "zh-Hans", "zh-Hans-CN"] { + assert_eq!(first_supported(&format!("{tag}\r\n")), Lang::from_code("zh-hans")); + } + for tag in ["zh-TW", "zh-HK", "zh-MO", "zh-Hant", "zh-Hant-HK"] { + assert_eq!(first_supported(&format!("{tag}\r\n")), Lang::from_code("zh-hant")); + } + assert_eq!(first_supported("en-US\r\n"), Some(Lang::EN)); + assert_eq!(first_supported("fr-FR\r\n"), Lang::from_code("fr")); + assert_eq!(first_supported("lo-LA\r\n"), Lang::from_code("lo")); + assert_eq!(first_supported("\r\n"), None); +} + +#[cfg(target_os = "windows")] +#[test] +fn neutral_locale_does_not_override_display_language() { + let fr = Lang::from_code("fr").unwrap(); + for tag in ["C", "C.UTF-8", "POSIX", "posix.UTF-8", "", " C.UTF-8 ", "unsupported"] { + assert_eq!(language_override(tag), None, "{tag}"); + assert_eq!(language_override(tag).or_else(|| first_supported("unsupported-XY\nfr-FR\nen-US")), Some(fr)); + } + assert_eq!(["C.UTF-8", "POSIX", "fr_FR.UTF-8"].into_iter().find_map(language_override), Some(fr)); + assert_eq!(language_override("fr_FR.UTF-8"), Some(fr)); + assert_eq!(language_override("en_US.UTF-8"), Some(Lang::EN)); + assert_eq!(language_override("ja_JP.UTF-8"), Some(JA())); +} + +#[cfg(target_os = "windows")] +#[test] +fn windows_display_language_query_returns_a_locale_tag() { + // One tag per line, as `first_supported` reads them: a machine with several display + // languages reports them all, so each line is checked rather than the list as a whole. + let list = windows_ui_language().expect("Windows should report a display language"); + let tags: Vec<&str> = list.lines().map(str::trim).filter(|t| !t.is_empty()).collect(); + assert!(!tags.is_empty(), "no display language in {list:?}"); + for tag in tags { + assert!(tag.split('-').all(|part| !part.is_empty() && part.bytes().all(|b| b.is_ascii_alphanumeric())), "not a locale tag: {tag:?}"); + } +} + +#[test] +fn preferences_resolve_with_fallback() { + assert_eq!(Lang::from_pref("ja"), JA()); + assert_eq!(Lang::from_pref("JA"), JA()); + assert_eq!(Lang::from_pref("en"), Lang::EN); + // `auto` and unknown codes follow the system (English under test). + assert_eq!(Lang::from_pref(AUTO), Lang::EN); + assert_eq!(Lang::from_pref("xx-unknown"), Lang::EN); + assert_eq!(normalize_pref("JA"), Some("ja")); + assert_eq!(normalize_pref("Auto"), Some(AUTO)); + assert_eq!(normalize_pref("xx"), None); +} + +#[test] +fn lookups_fall_back_to_english() { + assert_eq!(tr(JA(), "no such label"), "no such label"); + assert_eq!(tr(Lang::EN, "File"), "File"); + assert_eq!(tr(JA(), "File"), "ファイル"); + assert_eq!(tr(JA(), "日本語の文書.pdf"), "日本語の文書.pdf"); + assert_eq!(tr_id(JA(), "no.such.id", "File"), "ファイル"); + assert_eq!(tr_ctx(JA(), "no such context", "File"), "ファイル"); + assert!(has(JA(), "Save") && !has(Lang::EN, "Save")); +} + +#[test] +fn catalog_kinds_are_parsed_and_looked_up() { + let (c, errors) = Catalog::parse( + "# c\n\tHello\tこんにちは\n@id\tfile.save\t保存する\nmenu\tWindows\tウィンドウ群\n@plural\t{n} file|{n} files\t{n} 個\n\n", + 1, + ); + assert!(errors.is_empty(), "{errors:?}"); + assert_eq!(c.plain("Hello"), Some("こんにちは")); + assert_eq!(c.id("file.save"), Some("保存する")); + assert_eq!(c.contextual("menu", "Windows"), Some("ウィンドウ群")); + assert_eq!(c.contextual("other", "Windows"), None); + assert_eq!(c.plural("{n} file", "{n} files", 0), Some("{n} 個")); + assert_eq!(c.plural("{n} file", "{n} files", 5), Some("{n} 個"), "an index past the forms clamps"); +} + +#[test] +fn malformed_lines_are_reported_not_fatal() { + let (entries, errors) = parse_entries("\tok\tはい\nno tabs here\n\tonly\n\ta\tb\tc\textra\n\t\tempty source\n", 1); + assert_eq!(entries.len(), 1); + assert_eq!(errors.len(), 4, "{errors:?}"); + let (entries, errors) = parse_entries("\ta\\tb\tx\\ny\\\\z\n", 1); + assert!(errors.is_empty(), "{errors:?}"); + assert_eq!(entries, [Entry { context: String::new(), source: "a\tb".into(), translation: "x\ny\\z".into() }]); +} + +#[test] +fn strict_validation_skips_only_the_bad_line() { + for (text, forms, error) in [ + ("\tFile", 1, "context"), + ("\tFile\t", 1, "nonempty"), + ("\tFile\\q\tFiles", 1, "unknown escape"), + ("\tFile\tFiles\\", 1, "trailing backslash"), + ("\tOpen…\tOpen", 1, "ellipsis"), + ("\tOpen {file}\tOpen {name}", 1, "placeholders"), + ("@plural\tpage\tPages", 1, "one|other"), + ("@plural\t{n} page|{n} pages\t{n} pages", 2, "2 nonempty"), + ("@plural\t{n} page|{n} pages\tPages", 1, "placeholders"), + ("@unknown\tFile\tFiles", 1, "reserved context"), + ] { + // The bad line comes first; the good line after it still loads. + let (catalog, errors) = Catalog::parse(&format!("{text}\n\tSave\t保存\n"), forms); + assert_eq!(errors.len(), 1, "{text:?}: {errors:?}"); + assert!(errors[0].starts_with("line 1: ") && errors[0].contains(error), "{errors:?}"); + assert_eq!(catalog.plain("Save"), Some("保存"), "{text:?}"); + } + let (catalog, errors) = Catalog::parse("\tFile\tファイル\n\tFile\t別\n", 1); + assert!(errors.len() == 1 && errors[0].contains("line 2: duplicate"), "{errors:?}"); + assert_eq!(catalog.plain("File"), Some("ファイル"), "the first entry wins"); +} + +#[test] +fn catalogs_handle_escapes_crlf_and_distinct_contexts() { + let (catalog, errors) = + Catalog::parse("# comment\r\n\r\n\tLine\\nTab\\tPath\\\\\tOther\\nTab\\tPath\\\\\r\nweight\tLight\tThin\r\ntheme\tLight\tPale\r\n", 1); + assert!(errors.is_empty(), "{errors:?}"); + assert_eq!(catalog.plain("Line\nTab\tPath\\"), Some("Other\nTab\tPath\\")); + assert_eq!(catalog.contextual("weight", "Light"), Some("Thin")); + assert_eq!(catalog.contextual("theme", "Light"), Some("Pale")); +} + +#[test] +fn broken_catalog_falls_back_to_english() { + static BROKEN: LangInfo = LangInfo { + code: "broken", + name: "Broken test catalog", + source: "malformed\n\\", + plural: plural_none, + rtl: false, + catalog: OnceLock::new(), + }; + assert_eq!(tr(Lang(&BROKEN), "File"), "File"); + assert_eq!(trn(Lang(&BROKEN), 2, "{n} page", "{n} pages"), "2 pages"); +} + +#[test] +fn contextual_id_and_plural_lookups_have_english_fallbacks() { + static TEST: LangInfo = LangInfo { + code: "test", + name: "Test catalog", + source: "\tLight\tPlain light\nweight\tLight\tThin\n@id\tfile.open\tOpen dialog…\n@plural\t{n} page|{n} pages\tOne page: {n}|Many pages: {n}\n", + plural: plural_one_other, + rtl: false, + catalog: OnceLock::new(), + }; + let l = Lang(&TEST); + assert_eq!(tr_ctx(l, "weight", "Light"), "Thin"); + assert_eq!(tr_ctx(l, "theme", "Light"), "Plain light"); + assert_eq!(tr_ctx(l, "theme", "Unknown"), "Unknown"); + assert_eq!(tr_id(l, "file.open", "Reworded English…"), "Open dialog…"); + assert_eq!(tr_id(l, "unknown", "Light"), "Plain light"); + assert_eq!(tr_id(l, "unknown", "Unknown"), "Unknown"); + assert_eq!(trn(l, 1, "{n} page", "{n} pages"), "One page: 1"); + assert_eq!(trn(l, 0, "{n} page", "{n} pages"), "Many pages: 0"); + assert_eq!(trn(l, u64::MAX, "{n} page", "{n} pages"), format!("Many pages: {}", u64::MAX)); +} + +#[test] +fn formatting_reorders_parameters_without_reinterpreting_user_text() { + let args = [("file", "日本語-{n}.pdf"), ("n", "2")]; + assert_eq!(fmt("{n}: {file}; {unknown}", &args), "2: 日本語-{n}.pdf; {unknown}"); + assert_eq!(fmt("{file} / {file}", &args), "日本語-{n}.pdf / 日本語-{n}.pdf"); + assert_eq!(fmt("text {unfinished", &args), "text {unfinished"); + assert_eq!(fmt("}{n}{", &args), "}2{"); + assert_eq!(fmt("unchanged", &[]), "unchanged"); +} + +/// Every literal passed to `tl!(…)` or `crate::i18n::t(…)` in the UI source (test modules +/// aside), unescaped, including literals on a line of their own. +fn ui_literals() -> std::collections::BTreeSet { + let mut stack = vec![std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src")]; + let mut literals = std::collections::BTreeSet::new(); + while let Some(dir) = stack.pop() { + for entry in std::fs::read_dir(dir).expect("UI source directory") { + let path = entry.expect("UI source entry").path(); + if path.is_dir() { + if path.file_name().is_some_and(|name| name != "i18n") { + stack.push(path); + } + } else if path.extension().is_some_and(|ext| ext == "rs") { + let source = std::fs::read_to_string(path).expect("UI source file").replace("\r\n", "\n"); + let source = source.replace("crate::i18n::t(", "tl!("); + let mut rest = source.split("#[cfg(test)]\nmod ").next().unwrap_or_default(); + while let Some((_, after)) = rest.split_once("tl!(") { + let after = after.trim_start(); + let Some(after) = after.strip_prefix('"') else { + rest = after; + continue; + }; + let mut escaped = false; + let end = after + .char_indices() + .find_map(|(i, c)| { + if c == '"' && !escaped { + return Some(i); + } + escaped = c == '\\' && !escaped; + None + }) + .expect("closed tl! literal"); + let (raw, tail) = after.split_at(end); + let argument_end = tail.strip_prefix('"').expect("closing quote").trim_start(); + let literal_call = + argument_end.starts_with(')') || argument_end.strip_prefix(',').is_some_and(|rest| rest.trim_start().starts_with(')')); + if literal_call { + let label: String = serde_json::from_str(&format!("\"{raw}\"")).expect("UI literal escapes"); + literals.insert(label); + } + rest = tail.strip_prefix('"').expect("closing quote"); + } + } + } + } + literals +} + +/// Each translated label of a visible signature that runs into its value keeps a separator +/// at its end. +#[test] +fn signature_appearance_labels_keep_their_separator() { + let english = pdfcraft_engine::sign::AppearanceLabels::default(); + for language in LANGUAGES.iter().filter(|language| !language.source.is_empty()) { + let lang = Lang::from_code(language.code).expect("registered language"); + for (field, label) in english.fields() { + let said = tr(lang, label); + if field != "signed_by" && !lang.rtl() { + assert!(said.ends_with(' ') || said.ends_with(':'), "{}: the {field} label runs into its value: {said:?}", language.code); + } + } + } +} + +/// With craft-fonts, every Japanese translation has glyphs: with all interface faces (desktop) +/// and with BIZ UDPGothic Regular alone (the web build's only Japanese face). +#[test] +fn japanese_labels_have_glyphs() { + let craft = pdfcraft_fonts::ui_japanese_fonts(); + let Some(web) = craft.iter().find(|f| f.family == "BIZ UDPGothic" && f.style == "Regular") else { + eprintln!("skipping Japanese glyph checks: build with CRAFT_FONTS_DIR to run them"); + return; + }; + let (entries, _) = parse_entries(JA().0.source, 1); + let labels: String = entries.iter().flat_map(|e| e.translation.chars()).chain(JA().name().chars()).filter(|c| !c.is_control()).collect(); + let web_name = web.name(); + let mut web_only = crate::theme::font_definitions(); + for family in web_only.families.values_mut() { + family.retain(|name| !pdfcraft_fonts::CRAFT_FONTS.iter().any(|face| face.name() == *name) || *name == web_name); + } + use egui::epaint::text::{Fonts, TextOptions}; + for (build, defs) in [("desktop", crate::theme::font_definitions()), ("web", web_only)] { + let mut fonts = Fonts::new(TextOptions::default(), defs); + for id in [egui::FontId::proportional(13.0), egui::FontId::monospace(13.0), crate::theme::medium(13.0), crate::theme::semibold(17.0)] { + assert!(fonts.has_glyphs(&id, &labels), "{build}: {id:?} lacks a Japanese label glyph"); + } + } +} + +#[test] +fn history_labels_keep_captured_names() { + set_current(JA()); + assert_eq!(action_label("Insert pages from Save {e}.pdf"), "Save {e}.pdf からページを挿入"); + assert_eq!(command_label("Undo Insert pages from Save {e}.pdf"), "取り消し Save {e}.pdf からページを挿入"); + assert_eq!(action_label("Untranslated custom action"), "Untranslated custom action"); + assert_eq!(menu_label("file.open", "Open…"), "開く…"); + set_current(Lang::EN); + assert_eq!(command_label("Undo Fill in Save {e}"), "Undo Fill in Save {e}"); +} + +#[test] +fn current_language_is_per_thread() { + set_current(JA()); + assert_eq!(t("File"), "ファイル"); + std::thread::spawn(|| assert_eq!(current(), Lang::EN)).join().unwrap(); + set_current(Lang::EN); + assert_eq!(t("File"), "File"); +} + +#[test] +fn plurals_and_placeholders() { + 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"); + // No catalog has this `@plural` entry: Japanese falls back to the English forms. + assert_eq!(trn(JA(), 7, "{n} test item", "{n} test items"), "7 test items"); + 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"]); +} + +#[test] +fn language_persists_and_invalid_input_keeps_current_language() { + let mut app = crate::PdfCraftApp::default(); + assert_eq!(app.language, AUTO); + app.set_option("language", "JA").unwrap(); + assert_eq!(app.language, "ja"); + assert!(app.set_option("language", "xx").is_err()); + assert_eq!(app.language, "ja"); + let mut restored = crate::PdfCraftApp::default(); + restored.restore(&app.persist()); + assert_eq!(restored.language, "ja"); + restored.restore(r#"{"language":"xx"}"#); + assert_eq!(restored.language, "ja"); + app.set_option("language", AUTO).unwrap(); + assert_eq!(app.language, AUTO); + // Settings written before the system-locale default (no key) follow the system. + let mut legacy = crate::PdfCraftApp::default(); + legacy.restore("{}"); + assert_eq!(legacy.language, AUTO); +} + +#[test] +fn brazilian_portuguese_is_registered() { + let pt = Lang::from_code("pt-br").expect("pt-br registered"); + assert_eq!(pt.name(), "Português (Brasil)"); + assert_eq!(Lang::from_code("PT-BR"), Some(pt)); + assert_eq!(normalize_pref("pt-BR"), Some("pt-br")); + assert_eq!(normalize_pref("pt"), None, "only exact codes are preferences"); + assert_eq!(lang_from_tag("pt_BR.UTF-8"), Some(pt)); + assert_eq!(lang_from_tag("pt_PT"), Some(pt)); + assert_eq!(tr(pt, "Arquivo do usuário.pdf"), "Arquivo do usuário.pdf"); + assert_eq!((0..=3).map(|n| (pt.0.plural)(n)).collect::>(), [0, 0, 1, 1]); + let mut app = crate::PdfCraftApp::default(); + app.set_option("language", "pt-br").unwrap(); + assert_eq!(app.language, "pt-br"); + let mut restored = crate::PdfCraftApp::default(); + restored.restore(&app.persist()); + assert_eq!(restored.language, "pt-br"); +} + +#[test] +fn spanish_is_registered() { + let es = Lang::from_code("es").expect("es registered"); + assert_eq!(es.name(), "Español"); + assert_eq!(normalize_pref("ES"), Some("es")); + assert_eq!(lang_from_tag("es_ES.UTF-8"), Some(es)); + assert_eq!(lang_from_tag("es-MX"), Some(es)); + assert_eq!(first_supported("es-ES\r\nen-US"), Some(es)); + assert_eq!(tr(es, "Informe del usuario.pdf"), "Informe del usuario.pdf"); + let mut app = crate::PdfCraftApp::default(); + app.set_option("language", "es").unwrap(); + assert_eq!(app.language, "es"); + let mut restored = crate::PdfCraftApp::default(); + restored.restore(&app.persist()); + assert_eq!(restored.language, "es"); +} + +/// Each sentence the engine words for itself keeps its holes when it is said in Spanish, so +/// the document's own words still have somewhere to go: a translation that drops `{p}` or +/// `{}` would read as a sentence about nothing in particular. +#[test] +fn spanish_keeps_the_holes_in_what_the_engine_says() { + let es = Lang::from_code("es").expect("es registered"); + let lists: [&[&str]; 5] = [ + pdfcraft_engine::a11y::WORDINGS, + pdfcraft_engine::pdfa::WORDINGS, + pdfcraft_engine::sign::pdf::WORDINGS, + pdfcraft_engine::print::WORDINGS, + pdfcraft_engine::field_table::WORDINGS, + ]; + for wording in lists.into_iter().flatten() { + let said = tr(es, wording); + // `{}` is one hole; `{name}` holes are named, and each name must survive by itself. + assert_eq!(wording.matches("{}").count(), said.matches("{}").count(), "the Spanish of {wording:?} loses a hole: {said:?}"); + for hole in wording.split('{').skip(1).filter_map(|rest| rest.split_once('}')).map(|(name, _)| name).filter(|n| !n.is_empty()) { + assert!(said.contains(&format!("{{{hole}}}")), "the Spanish of {wording:?} loses {{{hole}}}: {said:?}"); + } + } +} + +#[test] +fn telugu_is_registered() { + let te = Lang::from_code("te").expect("te registered"); + assert_eq!(te.name(), "తెలుగు"); + assert_eq!(normalize_pref("TE"), Some("te")); + assert_eq!(lang_from_tag("te_IN.UTF-8"), Some(te)); + assert_eq!(first_supported("te-IN\r\nen-US"), Some(te)); + assert_eq!(tr(te, "నివేదిక.pdf"), "నివేదిక.pdf"); + let mut app = crate::PdfCraftApp::default(); + app.set_option("language", "te").unwrap(); + assert_eq!(app.language, "te"); + let mut restored = crate::PdfCraftApp::default(); + restored.restore(&app.persist()); + assert_eq!(restored.language, "te"); +} + +/// Telugu puts the verb last, so it translates "Undo {action}" as a whole phrase; a catalog +/// without that entry (Spanish) keeps the verb in front of the action. +#[test] +fn undo_and_redo_labels_use_the_catalog_phrase_when_there_is_one() { + let te = Lang::from_code("te").expect("te registered"); + set_current(te); + assert_eq!(command_label("Undo"), "రద్దు చేయి"); + assert_eq!(command_label("Undo Untranslated custom action"), "రద్దు చేయి: Untranslated custom action"); + assert_eq!(command_label("Redo Untranslated custom action"), "మళ్లీ చేయి: Untranslated custom action"); + let es = Lang::from_code("es").expect("es registered"); + set_current(es); + assert_eq!(command_label("Undo Untranslated custom action"), format!("{} Untranslated custom action", tr(es, "Undo"))); + set_current(Lang::EN); + assert_eq!(command_label("Undo Untranslated custom action"), "Undo Untranslated custom action"); +} + +/// A label that reads differently by use has its own contextual entry where a language needs +/// one; a language without it falls back to the plain translation. +#[test] +fn contextual_labels_fall_back_to_the_plain_translation() { + let te = Lang::from_code("te").expect("te registered"); + assert_eq!(tr_ctx(te, "signature pad", "Type"), "టైప్ చేయి"); + assert_eq!(tr(te, "Type"), "రకం"); + // A context no catalog defines, so the fallback stays tested as catalogs fill their gaps. + let es = Lang::from_code("es").expect("es registered"); + assert_eq!(tr_ctx(es, "no such context", "Type"), tr(es, "Type")); +} + +/// With a craft-fonts Telugu face, every Telugu translation has glyphs: with all interface +/// faces (desktop) and with the Telugu face as the only craft-fonts face (the web build keeps +/// it; Telugu labels must not need a Japanese or Arabic face). +#[test] +fn telugu_labels_have_glyphs() { + let telugu: Vec = pdfcraft_fonts::ui_telugu_fonts().iter().map(|f| f.name()).collect(); + if telugu.is_empty() { + eprintln!("skipping Telugu glyph checks: build with CRAFT_FONTS_DIR and a Telugu face to run them"); + return; + } + let te = Lang::from_code("te").expect("te registered"); + let (entries, _) = parse_entries(te.0.source, te.0.plural_forms()); + let labels: String = entries.iter().flat_map(|e| e.translation.chars()).chain(te.name().chars()).filter(|c| !c.is_control()).collect(); + let mut telugu_only = crate::theme::font_definitions(); + for family in telugu_only.families.values_mut() { + family.retain(|name| !pdfcraft_fonts::CRAFT_FONTS.iter().any(|face| face.name() == *name) || telugu.contains(name)); + } + use egui::epaint::text::{Fonts, TextOptions}; + for (build, defs) in [("all faces", crate::theme::font_definitions()), ("Telugu face only", telugu_only)] { + let mut fonts = Fonts::new(TextOptions::default(), defs); + for id in [egui::FontId::proportional(13.0), egui::FontId::monospace(13.0), crate::theme::medium(13.0), crate::theme::semibold(17.0)] { + assert!(fonts.has_glyphs(&id, &labels), "{build}: {id:?} lacks a Telugu label glyph"); + } + } +} + +#[test] +fn czech_is_registered_and_persists() { + let cs = Lang::from_code("cs").expect("cs registered"); + assert_eq!(cs.name(), "Čeština"); + assert_eq!(tr(cs, "Žluťoučký kůň.pdf"), "Žluťoučký kůň.pdf"); + assert_eq!((0..=6).map(|n| (cs.0.plural)(n)).collect::>(), [2, 0, 1, 1, 1, 2, 2]); + let mut app = crate::PdfCraftApp::default(); + app.set_option("language", "cs").unwrap(); + assert!(app.set_option("language", "cz").is_err()); + assert_eq!(app.language, "cs"); + let mut restored = crate::PdfCraftApp::default(); + restored.restore(&app.persist()); + assert_eq!(restored.language, "cs"); +} + +#[test] +fn turkish_is_registered_and_persists() { + let tr_lang = Lang::from_code("tr").expect("tr registered"); + assert_eq!(tr_lang.name(), "Türkçe"); + assert_eq!(tr(tr_lang, "Türkçe Belge.pdf"), "Türkçe Belge.pdf"); + assert_eq!((0..=6).map(|n| (tr_lang.0.plural)(n)).collect::>(), [0; 7]); + let mut app = crate::PdfCraftApp::default(); + app.set_option("language", "tr").unwrap(); + assert!(app.set_option("language", "tu").is_err()); + assert_eq!(app.language, "tr"); + let mut restored = crate::PdfCraftApp::default(); + restored.restore(&app.persist()); + assert_eq!(restored.language, "tr"); +} + +/// The code joins some labels with a space at either end, so these catalogs keep exactly the +/// source's, and write … rather than three dots. +#[test] +fn catalog_rows_keep_the_source_spacing_and_ellipsis() { + let edges = |s: &str| (s.len() - s.trim_start().len(), s.len() - s.trim_end().len()); + for code in ["cs", "tr", "vi"] { + let lang = Lang::from_code(code).expect("registered"); + let (entries, errors) = parse_entries(lang.0.source, lang.0.plural_forms()); + assert!(errors.is_empty(), "{code}: {errors:?}"); + for e in &entries { + assert!(!e.translation.contains("..."), "{code}: use … rather than three dots: {:?}", e.translation); + assert_eq!(edges(&e.translation), edges(&e.source), "{code}: stray whitespace: {:?}", e.translation); + } + } +} + +#[test] +fn simplified_chinese_is_registered() { + let zh = Lang::from_code("zh-hans").expect("zh-hans registered"); + assert_eq!(zh.name(), "简体中文"); + assert_eq!(normalize_pref("zh-Hans"), Some("zh-hans")); + assert_eq!(normalize_pref("zh"), None, "only exact codes are preferences"); + assert_eq!(tr(zh, "报告.pdf"), "报告.pdf"); +} + +#[test] +fn traditional_chinese_is_registered() { + let zh = Lang::from_code("zh-hant").expect("zh-hant registered"); + assert_eq!(zh.name(), "繁體中文"); + assert_eq!(normalize_pref("zh-Hant"), Some("zh-hant")); + assert_eq!(tr(zh, "報告.pdf"), "報告.pdf"); +} + +#[test] +fn french_is_registered() { + let fr = Lang::from_code("fr").expect("fr registered"); + assert_eq!(fr.name(), "Français"); + assert_eq!(normalize_pref("FR"), Some("fr")); + assert_eq!(lang_from_tag("fr_FR.UTF-8"), Some(fr)); + assert_eq!(lang_from_tag("fr-CA"), Some(fr)); + assert_eq!(first_supported("fr-FR\r\nen-US"), Some(fr)); + assert_eq!(tr(fr, "Rapport de l'utilisateur.pdf"), "Rapport de l'utilisateur.pdf"); + assert_eq!((0..=3).map(|n| (fr.0.plural)(n)).collect::>(), [0, 0, 1, 1]); + let mut app = crate::PdfCraftApp::default(); + app.set_option("language", "fr").unwrap(); + assert_eq!(app.language, "fr"); + let mut restored = crate::PdfCraftApp::default(); + restored.restore(&app.persist()); + assert_eq!(restored.language, "fr"); +} + +#[test] +fn german_is_registered() { + let de = Lang::from_code("de").expect("de registered"); + assert_eq!(de.name(), "Deutsch"); + assert_eq!(normalize_pref("DE"), Some("de")); + assert_eq!(lang_from_tag("de_DE.UTF-8"), Some(de)); + assert_eq!(lang_from_tag("de-AT"), Some(de)); + assert_eq!(lang_from_tag("de_CH"), Some(de)); + assert_eq!(first_supported("de-DE\r\nen-US"), Some(de)); + assert_eq!(tr(de, "Bericht des Nutzers.pdf"), "Bericht des Nutzers.pdf"); + assert_eq!((0..=3).map(|n| (de.0.plural)(n)).collect::>(), [1, 0, 1, 1]); + let mut app = crate::PdfCraftApp::default(); + app.set_option("language", "de").unwrap(); + assert_eq!(app.language, "de"); + let mut restored = crate::PdfCraftApp::default(); + restored.restore(&app.persist()); + assert_eq!(restored.language, "de"); +} + +#[test] +fn tool_names_inside_sentences_are_lowercased_except_in_german() { + set_current(Lang::EN); + assert_eq!(in_sentence("Text Box"), "text box"); + set_current(Lang::from_code("de").expect("de registered")); + assert_eq!(in_sentence("Textfeld"), "Textfeld"); + set_current(Lang::EN); +} + +#[test] +fn turkish_keeps_its_dotted_and_dotless_i_when_changing_case() { + set_current(Lang::from_code("tr").expect("tr registered")); + assert_eq!(upper_label("Advanced"), "GELİŞMİŞ"); + assert_eq!(upper_label("Signatures"), "İMZALAR"); + // A label with no Turkish row is still English and keeps the English rule. + assert_eq!(upper_label("Untranslated title"), "UNTRANSLATED TITLE"); + assert_eq!(in_sentence("İmza Alanı"), "imza alanı"); + assert_eq!(in_sentence("Işık"), "ışık"); + set_current(Lang::EN); + assert_eq!(upper_label("Advanced"), "ADVANCED"); + assert_eq!(in_sentence("Image"), "image"); +} + +#[test] +fn russian_is_registered() { + let ru = Lang::from_code("ru").expect("ru registered"); + assert_eq!(ru.name(), "Русский"); + assert_eq!(normalize_pref("RU"), Some("ru")); + assert_eq!(lang_from_tag("ru_RU.UTF-8"), Some(ru)); + assert_eq!(lang_from_tag("ru-BY"), Some(ru)); + assert_eq!(first_supported("ru-RU\r\nen-US"), Some(ru)); + assert_eq!(tr(ru, "Rapport de l'utilisateur.pdf"), "Rapport de l'utilisateur.pdf"); + // one (1), few (2-4), many (0, 5+), with 11-14 exception + assert_eq!((0..=3).map(|n| (ru.0.plural)(n)).collect::>(), [2, 0, 1, 1]); + let mut app = crate::PdfCraftApp::default(); + app.set_option("language", "ru").unwrap(); + assert_eq!(app.language, "ru"); + let mut restored = crate::PdfCraftApp::default(); + restored.restore(&app.persist()); + assert_eq!(restored.language, "ru"); +} + +#[test] +fn finnish_is_registered_and_persists() { + let fi = Lang::from_code("fi").expect("fi registered"); + assert_eq!(fi.name(), "Suomi"); + assert_eq!(normalize_pref("FI"), Some("fi")); + for tag in ["fi", "fi-FI", "fi_FI.UTF-8", "FI-fi"] { + assert_eq!(lang_from_tag(tag), Some(fi), "{tag}"); + } + // Filipino (`fil`) and Swedish in Finland (`sv-FI`) don't pick Finnish. + assert_ne!(lang_from_tag("fil"), Some(fi)); + assert_ne!(lang_from_tag("sv-FI"), Some(fi)); + let mut app = crate::PdfCraftApp::default(); + app.set_option("language", "FI").unwrap(); + assert_eq!(app.language, "fi"); + let mut restored = crate::PdfCraftApp::default(); + restored.restore(&app.persist()); + assert_eq!(restored.language, "fi"); +} + +#[test] +fn lithuanian_is_registered_and_persists() { + let lt = Lang::from_code("lt").expect("lt registered"); + assert_eq!(lt.name(), "Lietuvių"); + assert_eq!(normalize_pref("LT"), Some("lt")); + for tag in ["lt", "lt-LT", "lt_LT.UTF-8", "LT-lt"] { + assert_eq!(lang_from_tag(tag), Some(lt), "{tag}"); + } + let mut app = crate::PdfCraftApp::default(); + app.set_option("language", "LT").unwrap(); + assert_eq!(app.language, "lt"); + let mut restored = crate::PdfCraftApp::default(); + restored.restore(&app.persist()); + assert_eq!(restored.language, "lt"); + assert!(restored.set_option("language", "xx").is_err()); +} + +#[test] +fn norwegian_bokmal_is_registered_and_persists() { + let nb = Lang::from_code("nb").expect("nb registered"); + assert_eq!(nb.name(), "Norsk bokmål"); + assert_eq!(normalize_pref("NB"), Some("nb")); + assert_eq!(normalize_pref("no"), None, "only exact codes are preferences"); + // Current systems say `nb`; older ones and some Linux setups say `no`. Nynorsk is `nn`. + for tag in ["nb", "nb-NO", "nb_NO.UTF-8", "no", "no_NO.UTF-8", "NO-no", "nn", "nn_NO.UTF-8"] { + assert_eq!(lang_from_tag(tag), Some(nb), "{tag}"); + } + assert_eq!(first_supported("(\n \"nb-NO\",\n \"en-US\"\n)\n"), Some(nb)); + assert_eq!(first_supported("nn-NO\r\nen-US\r\n"), Some(nb)); + assert_eq!((0..=3).map(|n| (nb.0.plural)(n)).collect::>(), [1, 0, 1, 1]); + let mut app = crate::PdfCraftApp::default(); + app.set_option("language", "NB").unwrap(); + assert_eq!(app.language, "nb"); + let mut restored = crate::PdfCraftApp::default(); + restored.restore(&app.persist()); + assert_eq!(restored.language, "nb"); +} + +#[test] +fn vietnamese_is_registered_and_persists() { + let vi = Lang::from_code("vi").expect("vi registered"); + assert_eq!(vi.name(), "Tiếng Việt"); + assert_eq!(normalize_pref("Vi"), Some("vi")); + for tag in ["vi", "vi-VN", "vi_VN.UTF-8", "VI-vn"] { + assert_eq!(lang_from_tag(tag), Some(vi), "{tag}"); + } + assert_eq!(first_supported("vi-VN\r\nen-US"), Some(vi)); + assert_eq!((0..=3).map(|n| (vi.0.plural)(n)).collect::>(), [0, 0, 0, 0]); + let mut app = crate::PdfCraftApp::default(); + app.set_option("language", "vi").unwrap(); + assert_eq!(app.language, "vi"); + let mut restored = crate::PdfCraftApp::default(); + restored.restore(&app.persist()); + assert_eq!(restored.language, "vi"); + assert!(restored.set_option("language", "xx").is_err()); + assert_eq!(restored.language, "vi"); +} + +#[test] +fn romanian_is_registered_with_three_plural_forms() { + let ro = Lang::from_code("ro").expect("ro registered"); + for tag in ["ro", "ro-RO", "ro_RO.UTF-8", "RO-ro", "ro-MD"] { + assert_eq!(lang_from_tag(tag), Some(ro), "{tag}"); + } + assert_eq!(normalize_pref("Ro"), Some("ro")); + let info = LANGUAGES.iter().find(|language| language.code == "ro").expect("ro row"); + assert_eq!(info.plural_forms(), 3); + // one: 1; few: 0 and n % 100 in 1..=19 ("2 pagini", "101 pagini"); other: the "de" form ("20 de pagini"). + for (n, form) in [(1, 0), (0, 1), (2, 1), (19, 1), (101, 1), (119, 1), (20, 2), (100, 2), (120, 2), (1000, 2)] { + assert_eq!(plural_ro(n), form, "{n}"); + } +} + +#[test] +fn catalan_is_registered_and_persists() { + let ca = Lang::from_code("ca").expect("ca registered"); + assert_eq!(ca.name(), "Català"); + assert_eq!(normalize_pref("CA"), Some("ca")); + for tag in ["ca", "ca-ES", "ca_ES.UTF-8", "CA-es", "ca_ES@valencia"] { + assert_eq!(lang_from_tag(tag), Some(ca), "{tag}"); + } + assert_eq!(first_supported("ca-ES\r\nen-US"), Some(ca)); + // one (1), other (0, 2+), as in English + assert_eq!((0..=3).map(|n| (ca.0.plural)(n)).collect::>(), [1, 0, 1, 1]); + let mut app = crate::PdfCraftApp::default(); + app.set_option("language", "CA").unwrap(); + assert_eq!(app.language, "ca"); + let mut restored = crate::PdfCraftApp::default(); + restored.restore(&app.persist()); + assert_eq!(restored.language, "ca"); + assert!(restored.set_option("language", "xx").is_err()); + assert_eq!(restored.language, "ca"); +} + +#[test] +fn slovene_is_registered_and_persists() { + let sl = Lang::from_code("sl").expect("sl registered"); + assert_eq!(sl.name(), "Slovenščina"); + assert_eq!(normalize_pref("SL"), Some("sl")); + for tag in ["sl", "sl-SI", "sl_SI.UTF-8", "SL-si", "sl_SI@variant"] { + assert_eq!(lang_from_tag(tag), Some(sl), "{tag}"); + } + assert_eq!(first_supported("sl-SI\r\nen-US"), Some(sl)); + assert_eq!(tr(sl, "Poročilo {n}.pdf"), "Poročilo {n}.pdf"); + // one (1), two (2), few (3, 4), other (0, 5+) + assert_eq!((0..=5).map(|n| (sl.0.plural)(n)).collect::>(), [3, 0, 1, 2, 2, 3]); + let mut app = crate::PdfCraftApp::default(); + app.set_option("language", "SL").unwrap(); + assert_eq!(app.language, "sl"); + let mut restored = crate::PdfCraftApp::default(); + restored.restore(&app.persist()); + assert_eq!(restored.language, "sl"); + assert!(restored.set_option("language", "xx").is_err()); + assert_eq!(restored.language, "sl"); +} + +#[test] +fn albanian_is_registered_and_persists() { + let sq = Lang::from_code("sq").expect("sq registered"); + assert_eq!(sq.name(), "Shqip"); + assert_eq!(normalize_pref("SQ"), Some("sq")); + for tag in ["sq", "sq-AL", "sq_AL.UTF-8", "SQ-xk", "sq_XK@variant"] { + assert_eq!(lang_from_tag(tag), Some(sq), "{tag}"); + } + assert_eq!(first_supported("sq-AL\r\nen-US"), Some(sq)); + // one (1), other (0, 2+) + assert_eq!((0..=3).map(|n| (sq.0.plural)(n)).collect::>(), [1, 0, 1, 1]); + let mut app = crate::PdfCraftApp::default(); + app.set_option("language", "SQ").unwrap(); + assert_eq!(app.language, "sq"); + let mut restored = crate::PdfCraftApp::default(); + restored.restore(&app.persist()); + assert_eq!(restored.language, "sq"); + assert!(restored.set_option("language", "xx").is_err()); + assert_eq!(restored.language, "sq"); +} + +#[test] +fn slovene_plurals_cover_dual_few_teens_and_large_counts() { + let sl = Lang::from_code("sl").expect("sl registered"); + assert_eq!(sl.0.plural_forms(), 4); + for (counts, form) in [ + (vec![1, 101, 201, 1001, 1101], 0), + (vec![2, 102, 202, 1002], 1), + (vec![3, 4, 103, 104, 1003], 2), + (vec![0, 5, 10, 11, 12, 13, 14, 15, 20, 21, 22, 23, 24, 25, 31, 100, 111, 112, 121, 122, 1000, u64::MAX], 3), + ] { + for n in counts { + assert_eq!((sl.0.plural)(n), form, "{n}"); + } + } +} + +#[test] +fn indonesian_and_vietnamese_are_registered_and_persist() { + let id = Lang::from_code("id").expect("id registered"); + let vi = Lang::from_code("vi").expect("vi registered"); + assert_eq!(id.name(), "Bahasa Indonesia"); + assert_eq!(vi.name(), "Tiếng Việt"); + assert_eq!(normalize_pref("ID"), Some("id")); + assert_eq!(normalize_pref("Vi"), Some("vi")); + for tag in ["id", "id-ID", "id_ID.UTF-8", "ID-id"] { + assert_eq!(lang_from_tag(tag), Some(id), "{tag}"); + } + for tag in ["vi", "vi-VN", "vi_VN.UTF-8", "VI-vn"] { + assert_eq!(lang_from_tag(tag), Some(vi), "{tag}"); + } + assert_eq!(first_supported("id-ID\r\nen-US"), Some(id)); + assert_eq!(first_supported("vi-VN\r\nen-US"), Some(vi)); + // One CLDR plural form (CLDR `id`, `vi`): the message reads the same for every count. + assert_eq!((0..=3).map(|n| (id.0.plural)(n)).collect::>(), [0, 0, 0, 0]); + assert_eq!((0..=3).map(|n| (vi.0.plural)(n)).collect::>(), [0, 0, 0, 0]); + let mut app = crate::PdfCraftApp::default(); + app.set_option("language", "ID").unwrap(); + assert_eq!(app.language, "id"); + let mut restored = crate::PdfCraftApp::default(); + restored.restore(&app.persist()); + assert_eq!(restored.language, "id"); + let mut app = crate::PdfCraftApp::default(); + app.set_option("language", "vi").unwrap(); + assert_eq!(app.language, "vi"); + let mut restored = crate::PdfCraftApp::default(); + restored.restore(&app.persist()); + assert_eq!(restored.language, "vi"); + assert!(restored.set_option("language", "xx").is_err()); + assert_eq!(restored.language, "vi"); +} + +#[test] +fn hindi_and_korean_are_registered_and_persist() { + let hi = Lang::from_code("hi").expect("hi registered"); + let ko = Lang::from_code("ko").expect("ko registered"); + assert_eq!(hi.name(), "हिन्दी"); + assert_eq!(ko.name(), "한국어"); + assert_eq!(normalize_pref("HI"), Some("hi")); + assert_eq!(normalize_pref("Ko"), Some("ko")); + for tag in ["hi", "hi-IN", "hi_IN.UTF-8", "HI-in"] { + assert_eq!(lang_from_tag(tag), Some(hi), "{tag}"); + } + for tag in ["ko", "ko-KR", "ko_KR.UTF-8", "KO-kr"] { + assert_eq!(lang_from_tag(tag), Some(ko), "{tag}"); + } + assert_eq!(first_supported("hi-IN\r\nen-US"), Some(hi)); + assert_eq!(first_supported("ko-KR\r\nen-US"), Some(ko)); + // One plural form per catalog: the message reads the same for every count. + assert_eq!((0..=3).map(|n| (hi.0.plural)(n)).collect::>(), [0, 0, 0, 0]); + assert_eq!((0..=3).map(|n| (ko.0.plural)(n)).collect::>(), [0, 0, 0, 0]); + for code in ["hi", "ko"] { + let mut app = crate::PdfCraftApp::default(); + app.set_option("language", &code.to_uppercase()).unwrap(); + assert_eq!(app.language, code); + let mut restored = crate::PdfCraftApp::default(); + restored.restore(&app.persist()); + assert_eq!(restored.language, code); + assert!(restored.set_option("language", "xx").is_err()); + assert_eq!(restored.language, code); + } +} + +#[test] +fn bulgarian_is_registered() { + let bg = Lang::from_code("bg").expect("bg registered"); + assert_eq!(bg.name(), "Български"); + assert_eq!(normalize_pref("BG"), Some("bg")); + assert_eq!(lang_from_tag("bg_BG.UTF-8"), Some(bg)); + assert_eq!(lang_from_tag("bg-BG"), Some(bg)); + assert_eq!(first_supported("bg-BG\r\nen-US"), Some(bg)); + assert_eq!(tr(bg, "Rapport de l'utilisateur.pdf"), "Rapport de l'utilisateur.pdf"); + // one (1), other (0, 2+) + assert_eq!((0..=3).map(|n| (bg.0.plural)(n)).collect::>(), [1, 0, 1, 1]); + let mut app = crate::PdfCraftApp::default(); + app.set_option("language", "bg").unwrap(); + assert_eq!(app.language, "bg"); + let mut restored = crate::PdfCraftApp::default(); + restored.restore(&app.persist()); + assert_eq!(restored.language, "bg"); +} + +#[test] +fn hungarian_is_registered() { + let hu = Lang::from_code("hu").expect("hu registered"); + assert_eq!(hu.name(), "Magyar"); + assert_eq!(normalize_pref("HU"), Some("hu")); + assert_eq!(lang_from_tag("hu_HU.UTF-8"), Some(hu)); + assert_eq!(lang_from_tag("hu-HU"), Some(hu)); + assert_eq!(first_supported("hu-HU\r\nen-US"), Some(hu)); + assert_eq!(tr(hu, "Rapport de l'utilisateur.pdf"), "Rapport de l'utilisateur.pdf"); + // one (1), other (0, 2+) + assert_eq!((0..=3).map(|n| (hu.0.plural)(n)).collect::>(), [1, 0, 1, 1]); + let mut app = crate::PdfCraftApp::default(); + app.set_option("language", "hu").unwrap(); + assert_eq!(app.language, "hu"); + let mut restored = crate::PdfCraftApp::default(); + restored.restore(&app.persist()); + assert_eq!(restored.language, "hu"); +} + +#[test] +fn ukrainian_is_registered_and_persists() { + let uk = Lang::from_code("uk").expect("uk registered"); + assert_eq!(uk.name(), "Українська"); + assert_eq!(normalize_pref("UK"), Some("uk")); + for tag in ["uk", "uk-UA", "uk_UA.UTF-8", "UK-ua", "uk_UA@variant"] { + assert_eq!(lang_from_tag(tag), Some(uk), "{tag}"); + } + assert_eq!(first_supported("uk-UA\r\nen-US"), Some(uk)); + assert_eq!(first_supported("(\n \"uk-UA\",\n \"en-US\"\n)\n"), Some(uk)); + assert_eq!(tr(uk, "Звіт {n}.pdf"), "Звіт {n}.pdf"); + let mut app = crate::PdfCraftApp::default(); + app.set_option("language", "UK").unwrap(); + assert_eq!(app.language, "uk"); + let mut restored = crate::PdfCraftApp::default(); + restored.restore(&app.persist()); + assert_eq!(restored.language, "uk"); + assert!(restored.set_option("language", "xx").is_err()); + assert_eq!(restored.language, "uk"); +} + +#[test] +fn ukrainian_integer_plurals_cover_teens_and_large_counts() { + let uk = Lang::from_code("uk").expect("uk registered"); + assert_eq!(uk.0.plural_forms(), 3); + for (counts, form) in [ + (vec![1, 21, 31, 101, 121, 1001], 0), + (vec![2, 3, 4, 22, 23, 24, 102, 104, 122, 1002], 1), + (vec![0, 5, 10, 11, 12, 13, 14, 19, 20, 25, 100, 110, 111, 112, 114, 1000, u64::MAX], 2), + ] { + for n in counts { + assert_eq!((uk.0.plural)(n), form, "{n}"); + } + } +} + +fn arabic() -> Lang { + Lang::from_code("ar").expect("ar registered") +} + +#[test] +fn arabic_is_registered_and_persists() { + let ar = arabic(); + assert_eq!(ar.name(), "العربية"); + assert!(ar.rtl()); + let rtl: Vec<&str> = Lang::all().filter(|l| l.rtl()).map(|l| l.code()).collect(); + assert_eq!(rtl, ["ar", "ckb", "fa"], "Arabic, Kurdish and Persian are the right-to-left languages"); + assert_eq!(normalize_pref("AR"), Some("ar")); + for tag in ["ar", "ar-MA", "ar_EG.UTF-8", "AR-sa", "ar_DZ@variant"] { + assert_eq!(lang_from_tag(tag), Some(ar), "{tag}"); + } + assert_eq!(first_supported("ar-MA\r\nen-US"), Some(ar)); + // Unknown strings and user text are never reordered by a lookup. + assert_eq!(tr(ar, "تقرير نهائي {n}.pdf"), "تقرير نهائي {n}.pdf"); + let mut app = crate::PdfCraftApp::default(); + app.set_option("language", "AR").unwrap(); + assert_eq!(app.language, "ar"); + let mut restored = crate::PdfCraftApp::default(); + restored.restore(&app.persist()); + assert_eq!(restored.language, "ar"); + assert!(restored.set_option("language", "xx").is_err()); + assert_eq!(restored.language, "ar"); +} + +/// Catalog lookups stay in logical order; the egui 0.37 fork owns bidi shaping and display +/// reordering (E5/E12), so preordering here would reverse Arabic twice. Accessibility names, +/// copied labels and search therefore see the source string. +#[test] +fn arabic_catalog_stays_logical() { + let ar = arabic(); + assert_eq!(tr(ar, "Save as…"), "حفظ باسم…"); + assert_eq!(tr(ar, "About PdfCraft"), "حول PdfCraft"); + assert_eq!(tr(ar, "Name:"), "الاسم:"); + assert_eq!(tr(ar, "Issued by: "), "صادرة عن: "); + // Placeholders survive in their logical positions. + assert_eq!(tr(ar, "Couldn't open {name}: {e}"), "تعذّر فتح {name}: {e}"); + assert_eq!(fmt(tr(ar, "Couldn't open {name}: {e}"), &[("name", "a {e}.pdf"), ("e", "x")]), "تعذّر فتح a {e}.pdf: x"); + assert_eq!(fmt(tr(ar, "Page {p} of {n}"), &[("p", "3"), ("n", "12")]), "الصفحة 3 من 12"); + set_current(ar); + let command = command_label("Undo Insert pages from a.pdf"); + assert!(command.contains("a.pdf") && command.contains("إدراج صفحات"), "logical command label: {command:?}"); + assert_eq!(menu_label("file.saveAs", "Save as…"), "حفظ باسم…"); + set_current(Lang::EN); +} + +#[test] +fn arabic_plurals_follow_the_six_cldr_forms() { + let ar = arabic(); + assert_eq!(ar.0.plural_forms(), 6); + let form = |n| (ar.0.plural)(n); + assert_eq!([0, 1, 2].map(form), [0, 1, 2]); + assert_eq!([3, 10, 103, 110, 1003].map(form), [3; 5]); + assert_eq!([11, 99, 111, 199, 1011].map(form), [4; 5]); + assert_eq!([100, 101, 102, 200, 1000, u64::MAX - 13].map(form), [5; 6]); +} + +fn persian() -> Lang { + Lang::from_code("fa").expect("fa registered") +} + +#[test] +fn persian_is_registered_and_persists() { + let fa = persian(); + assert_eq!(fa.name(), "فارسی"); + assert!(fa.rtl()); + // CLDR `fa` (one: i = 0 or n = 1): 0 and 1 are singular, every other count the plural. + assert_eq!(fa.0.plural_forms(), 2); + assert_eq!([0, 1, 2, 11].map(|n| (fa.0.plural)(n)), [0, 0, 1, 1]); + assert_eq!(normalize_pref("FA"), Some("fa")); + for tag in ["fa", "fa-IR", "fa_IR.UTF-8", "FA-af", "fa_IR@calendar=persian"] { + assert_eq!(lang_from_tag(tag), Some(fa), "{tag}"); + } + assert_eq!(first_supported("fa-IR\r\nen-US"), Some(fa)); + // Lookups return Persian in logical order; epaint handles visual ordering. + assert_eq!(tr(fa, "Save"), "ذخیره"); + assert_eq!(tr(fa, "Cancel"), "لغو"); + assert_eq!(tr(fa, "File"), "فایل"); + // Unknown strings and user text are never reordered by a lookup. + assert_eq!(tr(fa, "گزارش نهایی {n}.pdf"), "گزارش نهایی {n}.pdf"); + let mut app = crate::PdfCraftApp::default(); + app.set_option("language", "FA").unwrap(); + assert_eq!(app.language, "fa"); + let mut restored = crate::PdfCraftApp::default(); + restored.restore(&app.persist()); + assert_eq!(restored.language, "fa"); + assert!(restored.set_option("language", "xx").is_err()); + assert_eq!(restored.language, "fa"); +} + +/// With a craft-fonts Arabic face, every Arabic translation has glyphs: with all interface +/// faces (desktop) and with the Arabic face as the only craft-fonts face (the web build keeps +/// it; Arabic labels must not need a Japanese face). +#[test] +fn arabic_labels_have_glyphs() { + let faces: Vec = pdfcraft_fonts::ui_arabic_fonts().iter().map(|f| f.name()).collect(); + if faces.is_empty() { + eprintln!("skipping Arabic glyph checks: build with CRAFT_FONTS_DIR and an Arabic face to run them"); + return; + } + let ar = arabic(); + let (entries, _) = parse_entries(ar.0.source, ar.0.plural_forms()); + let labels: String = entries.iter().flat_map(|e| e.translation.chars()).chain(ar.name().chars()).filter(|c| !c.is_control()).collect(); + let mut arabic_only = crate::theme::font_definitions(); + for family in arabic_only.families.values_mut() { + family.retain(|name| !pdfcraft_fonts::CRAFT_FONTS.iter().any(|face| face.name() == *name) || faces.contains(name)); + } + use egui::epaint::text::{Fonts, TextOptions}; + for (build, defs) in [("all faces", crate::theme::font_definitions()), ("Arabic face only", arabic_only)] { + let mut fonts = Fonts::new(TextOptions::default(), defs); + for id in [egui::FontId::proportional(13.0), egui::FontId::monospace(13.0), crate::theme::medium(13.0), crate::theme::semibold(17.0)] { + let missing: std::collections::BTreeSet = labels.chars().filter(|c| !fonts.has_glyphs(&id, &c.to_string())).collect(); + assert!(missing.is_empty(), "{build}: {id:?} lacks Arabic label glyphs {missing:?}"); + } + } +} + +/// Every bundled catalog is well-formed and consistent with its sources. +#[test] +fn bundled_catalogs_are_consistent() { + let mut codes = std::collections::HashSet::new(); + for l in &LANGUAGES { + assert!(codes.insert(l.code), "duplicate code {}", l.code); + assert!(l.code == l.code.to_ascii_lowercase() && !l.name.is_empty(), "{}", l.code); + let (entries, errors) = parse_entries(l.source, l.plural_forms()); + assert!(errors.is_empty(), "{}: {errors:?}", l.code); + for e in entries.iter().filter(|e| e.context == "@id") { + let command = pdfcraft_engine::commands::command(&e.source); + let Some(command) = command else { panic!("{}: unknown command id {:?}", l.code, e.source) }; + assert_eq!(placeholders(&e.translation), placeholders(command.label), "{}: {}", l.code, e.source); + assert_eq!(e.translation.ends_with('…'), command.label.ends_with('…'), "{}: ellipsis mismatch: {}", l.code, e.source); + } + } +} + +/// The translation gaps report (`cargo xtask i18n-report`, a step of `cargo xtask ci`): for +/// each language, how many English strings it has no translation for, and the first few. +/// It never fails: a missing string shows in English. +/// +/// The English strings are the UI source's literals, the commands, All tools, the engine's +/// own wordings, and every key of every bundled catalog (which covers contextual, plural and +/// dynamically looked-up strings the scan can't see). +#[test] +fn translation_gaps_report() { + use std::collections::BTreeSet; + const SHOWN: usize = 3; + let mut plain: BTreeSet = ui_literals(); + let mut add = |s: &str| { + plain.insert(s.to_string()); + }; + for command in pdfcraft_engine::commands::COMMANDS { + add(command.label); + command.menu.into_iter().for_each(&mut add); + } + for group in pdfcraft_engine::catalog::TOOL_GROUPS { + add(group.label); + for section in group.sections { + add(section.title); + section.items.iter().for_each(|item| add(item.label)); + } + } + pdfcraft_engine::catalog::TOOL_CATEGORIES.iter().for_each(|category| add(category.label)); + let engine: [&[&str]; 9] = [ + pdfcraft_engine::a11y::WORDINGS, + pdfcraft_engine::a11y::REPORT_WORDS, + pdfcraft_engine::pdfa::WORDINGS, + pdfcraft_engine::sign::pdf::WORDINGS, + pdfcraft_engine::sign::pdf::kind::ALL, + pdfcraft_engine::print::WORDINGS, + pdfcraft_engine::EDIT_LABELS, + pdfcraft_engine::BATCH_LABELS, + pdfcraft_engine::field_table::WORDINGS, + ]; + engine.into_iter().flatten().for_each(|s| add(s)); + pdfcraft_engine::sign::AppearanceLabels::default().fields().into_iter().for_each(|(_, label)| add(label)); + crate::credits::NameMode::ALL.iter().for_each(|mode| add(mode.label())); + for key in crate::credits::SortKey::ALL { + let (label, header) = key.label(); + add(label); + add(header); + } + crate::credits::MODEL_COLUMNS.iter().for_each(|s| add(s)); + // (context, source) keys; `@id` rows only reword a command label, which is in `plain`. + let mut keys: BTreeSet<(String, String)> = plain.into_iter().map(|s| (String::new(), s)).collect(); + for language in &LANGUAGES { + let (entries, _) = parse_entries(language.source, language.plural_forms()); + keys.extend(entries.into_iter().filter(|e| e.context != "@id").map(|e| (e.context, e.source))); + } + let mut report = format!("Translation gaps: {} English strings\n", keys.len()); + for lang in Lang::all().filter(|l| *l != Lang::EN) { + let catalog = lang.catalog(); + let missing: Vec<&(String, String)> = keys + .iter() + .filter(|(context, source)| match context.as_str() { + "" => catalog.plain(source).is_none(), + "@plural" => source.split_once('|').is_none_or(|(one, other)| catalog.plural(one, other, 0).is_none()), + // A contextual lookup falls back to the plain translation. + context => catalog.contextual(context, source).is_none() && catalog.plain(source).is_none(), + }) + .collect(); + let mut line = format!(" {:<8} {:>5} missing", lang.code(), missing.len()); + for (i, (context, source)) in missing.iter().take(SHOWN).enumerate() { + let mut shown: String = source.chars().take(48).collect(); + if shown.len() < source.len() { + shown.push('…'); + } + let key = if context.is_empty() { format!("{shown:?}") } else { format!("{context} / {shown:?}") }; + line.push_str(if i == 0 { ": " } else { ", " }); + line.push_str(&key); + } + if missing.len() > SHOWN { + line.push_str(", …"); + } + report.push_str(&line); + report.push('\n'); + } + println!("{REPORT_START}\n{report}{REPORT_END}"); +} + +/// The lines `cargo xtask i18n-report` prints from the test output lie between these. +const REPORT_START: &str = "=== translation gaps ==="; +const REPORT_END: &str = "=== end of translation gaps ==="; + +/// Preferences ▸ Date format ▸ Language offers exactly the interface languages, in order. +#[test] +fn date_languages_are_the_interface_languages() { + let interface: Vec<(&str, &str)> = Lang::all().map(|l| (l.code(), l.name())).collect(); + let dates: Vec<(&str, &str)> = pdfcraft_engine::dates::DATE_LANGUAGES.iter().map(|l| (l.code, l.name)).collect(); + assert_eq!(dates, interface); +}