i18n: missing translations are reported, never a failure (#2729)

A message a catalog lacks, or translates as an empty string, shows in
English and fails nothing:

- build.rs no longer requires every formats catalog to carry the same
  messages, and tr_format! gets a last arm that formats the English
  source, so a message no catalog has builds.
- Coverage tests (every language translates X) and tests of one
  language's exact wording are gone; the language-switching, layout,
  date, placeholder and user-value tests read the catalog's own value
  instead of a hard-coded translation.
- i18n::tests::translation_gaps_report lists, per language, the plain
  and format messages it lacks and its untranslated What's New lines.
  It always passes; cargo xtask ci
  runs it from the test step's binary after the tests.
- catalogs_load checks that every catalog file is a JSON object of
  strings with each key once; it.json named "Dimensions" twice.
This commit is contained in:
aiongg
2026-10-23 16:42:28 -04:00
committed by GitHub
parent d71b83b2bc
commit b264557916
18 changed files with 495 additions and 950 deletions
-4
View File
@@ -456,10 +456,6 @@ mod tests {
set_language(*language); set_language(*language);
assert!(seen.insert(structure_of(&bar)), "{language:?} shares another language's menu structure key"); assert!(seen.insert(structure_of(&bar)), "{language:?} shares another language's menu structure key");
} }
set_language(Locale::ZhHant);
assert_eq!(label_text("File"), "檔案");
assert_eq!(label_text("Settings…"), "設定…");
assert_eq!(label_text("Quit LightCraft"), "結束 LightCraft");
set_language(Locale::En); set_language(Locale::En);
} }
+8 -16
View File
@@ -7,9 +7,9 @@
//! `{:+}`… keep their meaning). The English source is the fallback arm. Adding a language means //! `{:+}`… keep their meaning). The English source is the fallback arm. Adding a language means
//! adding its file (and its `language_table!` entry); this script picks the file up by name. //! adding its file (and its `language_table!` entry); this script picks the file up by name.
//! //!
//! Every catalog must carry the same messages: a `tr_format!` call whose message is in no catalog //! A message a language lacks (or translates as an empty string) shows the English source: that
//! does not compile, so a message missing from one language would otherwise go unnoticed until it //! language has no arm for it, and a message no catalog has formats the English source through the
//! shows English. //! macro's last arm. `i18n::tests::translation_gaps_report` lists the gaps per language.
//! //!
//! It also bakes the contributor credits into the binary as static Rust tables (`$OUT_DIR/credits.rs`, //! It also bakes the contributor credits into the binary as static Rust tables (`$OUT_DIR/credits.rs`,
//! included by `src/credits.rs`; craftrules `standards/contributors.md`): commit stats from //! included by `src/credits.rs`; craftrules `standards/contributors.md`): commit stats from
@@ -61,25 +61,14 @@ fn generate() -> Result<String, Box<dyn std::error::Error>> {
let Some(code) = name.strip_suffix("-formats.json").filter(|code| *code != "en") else { continue }; let Some(code) = name.strip_suffix("-formats.json").filter(|code| *code != "en") else { continue };
println!("cargo:rerun-if-changed=locales/{name}"); println!("cargo:rerun-if-changed=locales/{name}");
let variant = variant_for(code)?; let variant = variant_for(code)?;
let messages: BTreeMap<String, String> = let mut messages: BTreeMap<String, String> =
serde_json::from_str(&fs::read_to_string(&path)?).map_err(|error| format!("locales/{name}: {error}"))?; serde_json::from_str(&fs::read_to_string(&path)?).map_err(|error| format!("locales/{name}: {error}"))?;
messages.retain(|_, translated| !translated.is_empty());
catalogs.push((code.to_string(), variant, messages)); catalogs.push((code.to_string(), variant, messages));
} }
catalogs.sort_by(|a, b| a.0.cmp(&b.0)); catalogs.sort_by(|a, b| a.0.cmp(&b.0));
// Every catalog translates the same messages.
let all: BTreeSet<&String> = catalogs.iter().flat_map(|(_, _, messages)| messages.keys()).collect(); let all: BTreeSet<&String> = catalogs.iter().flat_map(|(_, _, messages)| messages.keys()).collect();
let mut problems = Vec::new();
for (code, _, messages) in &catalogs {
for key in &all {
if !messages.contains_key(*key) {
problems.push(format!("locales/{code}-formats.json is missing {key:?}"));
}
}
}
if !problems.is_empty() {
return Err(problems.join("\n").into());
}
let mut source = String::from(concat!( let mut source = String::from(concat!(
"/// Translate a format string, checked by `format!` in every language.\n", "/// Translate a format string, checked by `format!` in every language.\n",
@@ -98,6 +87,9 @@ fn generate() -> Result<String, Box<dyn std::error::Error>> {
source.push_str(&format!(" _ => format!({en} $(, $($args)*)?),\n }}\n }};\n")); source.push_str(&format!(" _ => format!({en} $(, $($args)*)?),\n }}\n }};\n"));
} }
source.push_str(concat!( source.push_str(concat!(
" ($english:literal $(, $($args:tt)*)?) => {\n",
" format!($english $(, $($args)*)?)\n",
" };\n",
"}\n", "}\n",
"\n", "\n",
"// `macro_rules!` is textual: the module re-export is what lets call sites say\n", "// `macro_rules!` is textual: the module re-export is what lets call sites say\n",
-1
View File
@@ -2344,7 +2344,6 @@
"Loupe Info 1": "Info lente 1", "Loupe Info 1": "Info lente 1",
"Loupe Info 2": "Info lente 2", "Loupe Info 2": "Info lente 2",
"Use Defaults": "Usa predefiniti", "Use Defaults": "Usa predefiniti",
"Dimensions": "Dimensioni",
"Camera & Lens": "Fotocamera e obiettivo", "Camera & Lens": "Fotocamera e obiettivo",
"View → View Options chooses the lines Info 1 and Info 2 show.": "Visualizza → Opzioni di visualizzazione sceglie le righe di Info 1 e Info 2." "View → View Options chooses the lines Info 1 and Info 2 show.": "Visualizza → Opzioni di visualizzazione sceglie le righe di Info 1 e Info 2."
} }
+1 -4
View File
@@ -2495,7 +2495,7 @@ mod tests {
assert!(!row.cancellable, "{row:?}"); assert!(!row.cancellable, "{row:?}");
assert!(h.app.session.activity.cancel(row.id).is_err(), "Photos can't be stopped once asked"); assert!(h.app.session.activity.cancel(row.id).is_err(), "Photos can't be stopped once asked");
let current = h.app.export.as_ref().map(|e| e.progress.lock().unwrap().1.clone()).unwrap_or_default(); let current = h.app.export.as_ref().map(|e| e.progress.lock().unwrap().1.clone()).unwrap_or_default();
assert_eq!(current, "Wird zu Apple Fotos hinzugefügt…"); assert_eq!(current, crate::i18n::Locale::De.tr("Adding to Apple Photos…"));
// the row says so on the next frame (issue #345) // the row says so on the next frame (issue #345)
h.step(); h.step();
let row = h.app.session.activity.list().into_iter().find(|r| r.kind == "export").expect("the export's row"); let row = h.app.session.activity.list().into_iter().find(|r| r.kind == "export").expect("the export's row");
@@ -4570,9 +4570,6 @@ mod tests {
assert_eq!(crate::i18n::language(), locale); assert_eq!(crate::i18n::language(), locale);
for (mode, key) in [("basic", "Info Overlay: File"), ("exposure", "Info Overlay: Exposure"), ("off", "Info Overlay Off")] { for (mode, key) in [("basic", "Info Overlay: File"), ("exposure", "Info Overlay: Exposure"), ("off", "Info Overlay Off")] {
let expected = locale.tr(key); let expected = locale.tr(key);
if locale != Locale::En {
assert_ne!(expected, key, "this locale must translate the tested message");
}
assert_eq!(h.request("ui.key", json!({"key": "i", "cmd": true}), t)["ok"], true); assert_eq!(h.request("ui.key", json!({"key": "i", "cmd": true}), t)["ok"], true);
assert_eq!(serde_json::to_value(h.app.ui.info_overlay).unwrap(), json!(mode)); assert_eq!(serde_json::to_value(h.app.ui.info_overlay).unwrap(), json!(mode));
assert_eq!(h.app.ui.toast.as_ref().expect("overlay toast").0, expected); assert_eq!(h.app.ui.toast.as_ref().expect("overlay toast").0, expected);
+365 -788
View File
File diff suppressed because it is too large Load Diff
+2 -10
View File
@@ -684,25 +684,17 @@ mod tests {
} }
#[test] #[test]
fn skipped_images_are_one_line_in_the_ui_language() { fn skipped_images_are_one_line() {
let value = json!({"report": {"photos": 3, "collections": 0, "warnings": ["w1", "w2"], "skipped": [ let value = json!({"report": {"photos": 3, "collections": 0, "warnings": ["w1", "w2"], "skipped": [
{"image": 7, "reason": "missingFile"}, {"image": 7, "reason": "missingFile"},
{"image": 8, "reason": "missingRoot"}, {"image": 8, "reason": "missingRoot"},
{"image": 9, "reason": "masterSkipped"}, {"image": 9, "reason": "masterSkipped"},
]}}); ]}});
let details = |locale| {
crate::i18n::set_language(locale);
let dialog = result_dialog(&value, "done");
crate::i18n::set_language(crate::i18n::Locale::En); crate::i18n::set_language(crate::i18n::Locale::En);
let crate::state::Dialog::LightroomResult { details, .. } = dialog else { panic!("not a Lightroom result") }; let crate::state::Dialog::LightroomResult { details: en, .. } = result_dialog(&value, "done") else { panic!("not a Lightroom result") };
details
};
let en = details(crate::i18n::Locale::En);
assert_eq!(en.len(), 4, "{en:?}"); assert_eq!(en.len(), 4, "{en:?}");
assert_eq!(en[1], "3 catalog images were not imported because their records in the catalog are incomplete."); assert_eq!(en[1], "3 catalog images were not imported because their records in the catalog are incomplete.");
assert_eq!(&en[2..], ["w1", "w2"]); assert_eq!(&en[2..], ["w1", "w2"]);
let es = details(crate::i18n::Locale::Es);
assert_eq!(es[1], "No se importaron 3 imágenes del catálogo porque sus registros en el catálogo están incompletos.");
} }
#[test] #[test]
-12
View File
@@ -1533,18 +1533,6 @@ mod tests {
} }
} }
#[test]
fn library_context_menu_labels_have_french_translations() {
let saved = crate::i18n::language();
crate::i18n::set_language(crate::i18n::Locale::Fr);
for (label, expected) in
[("Pick", "Sélectionnée"), ("Reject", "Rejeter"), ("Group into Stack", "Regrouper en pile"), ("HDR Panorama…", "Panorama HDR…")]
{
assert_eq!(crate::i18n::tr(label), expected);
}
crate::i18n::set_language(saved);
}
#[test] #[test]
fn square_grid_groups_and_auto_levels() { fn square_grid_groups_and_auto_levels() {
let l = layout(&[1.0; 5], &[(0, 3), (3, 2)], 400.0, 100.0, true); let l = layout(&[1.0; 5], &[(0, 3), (3, 2)], 400.0, 100.0, true);
+29 -41
View File
@@ -520,6 +520,35 @@ fn in_tree(tree: &[KeywordNode], path: &str) -> bool {
tree.iter().any(|n| same(&n.path, path) || (lightcraft_catalog::keywords::is_under(path, &n.path) && in_tree(&n.children, path))) tree.iter().any(|n| same(&n.path, path) || (lightcraft_catalog::keywords::is_under(path, &n.path) && in_tree(&n.children, path)))
} }
/// The labels the Keyword List and its dialogs pass to `tr` through variables, out of reach of a
/// literal search: the translation gaps report (`i18n`) checks them.
#[cfg(test)]
pub(crate) const VARIABLE_LABELS: &[&str] = &[
"Include on Export",
"Export Containing Keywords",
"Export Synonyms",
"Person",
"Merge Keywords",
"No keywords yet",
"No keywords match",
"Add to Selected Photos",
"Remove from Selected Photos",
"Keywords",
"& Containing",
"Will Export",
"No keywords are exported",
"Add to All Selected Photos",
"Set name",
"Edit Set…",
"Edit Keyword Set",
"A keyword set needs a name",
"Recent Keywords",
"Keywords (⌥1–⌥9)",
"Save as a new set",
"Import Keywords…",
"Export Keywords…",
];
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use lightcraft_catalog::{Op, Photo, Source}; use lightcraft_catalog::{Op, Photo, Source};
@@ -589,45 +618,4 @@ mod tests {
let (c, ids) = library(&[&["Ärzte", "ÄRZTE"], &[]]); let (c, ids) = library(&[&["Ärzte", "ÄRZTE"], &[]]);
assert_eq!(Ticks::of(&c, &ids).tick("ärzte"), Tick::Some); assert_eq!(Ticks::of(&c, &ids).tick("ärzte"), Tick::Some);
} }
/// The labels the Keyword List and its dialogs pass to `tr` through variables (out of reach of
/// a literal search) are translated in every language.
#[test]
fn labels_passed_through_variables_are_translated() {
use crate::i18n::Locale;
let labels = [
"Include on Export",
"Export Containing Keywords",
"Export Synonyms",
"Person",
"Merge Keywords",
"No keywords yet",
"No keywords match",
"Add to Selected Photos",
"Remove from Selected Photos",
"Keywords",
"& Containing",
"Will Export",
"No keywords are exported",
"Add to All Selected Photos",
"Set name",
"Edit Set…",
"Edit Keyword Set",
"A keyword set needs a name",
"Recent Keywords",
"Keywords (⌥1–⌥9)",
"Save as a new set",
"Import Keywords…",
"Export Keywords…",
];
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("locales");
for code in ["de", "es", "fr", "pt-br", "ru", "uk", "it", "ka", "ja", "zh-hans", "zh-hant", "pl", "cs", "sv", "fi", "id", "vi"] {
let text = std::fs::read_to_string(dir.join(format!("{code}.json"))).unwrap();
let catalog: std::collections::HashMap<String, String> = serde_json::from_str(&text).unwrap();
for label in labels {
assert!(catalog.contains_key(label), "{code}: “{label}”");
}
}
assert_eq!(Locale::ALL.len(), 18, "a language added: list its file above");
}
} }
-19
View File
@@ -897,25 +897,6 @@ fn brush_settings(app: &mut LightcraftApp, ui: &mut egui::Ui) {
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn masking_context_actions_use_catalog_translations() {
let saved = crate::i18n::language();
crate::i18n::set_language(crate::i18n::Locale::Fr);
for (english, translated) in [
("Duplicate Mask", "Dupliquer le masque"),
("Invert Mask", "Inverser le masque"),
("Hide Mask", "Masquer le masque"),
("Move Up", "Monter"),
("Brush", "Pinceau"),
("Luminance Range", "Plage de luminance"),
] {
assert_eq!(menu_label(english), translated, "{english}");
}
// An arbitrary user name has no catalog entry and is left unchanged.
assert_eq!(menu_label("My custom mask"), "My custom mask");
crate::i18n::set_language(saved);
}
/// The grid keeps four 52 pt tiles when the labels fit, and gives a long (translated) label /// The grid keeps four 52 pt tiles when the labels fit, and gives a long (translated) label
/// room by dropping a column rather than drawing it past its tile. /// room by dropping a column rather than drawing it past its tile.
#[test] #[test]
+1 -9
View File
@@ -343,20 +343,12 @@ mod import_review {
} }
} }
/// Every shipped locale paints translated destination choices and initializes a translated new name. /// Every shipped locale paints its own destination choices and names a new album in its language.
#[test] #[test]
fn import_album_picker_localizes_choices_in_every_shipped_language() { fn import_album_picker_localizes_choices_in_every_shipped_language() {
use crate::i18n::Locale; use crate::i18n::Locale;
let Review { mut h, .. } = review(); let Review { mut h, .. } = review();
for locale in Locale::ALL { for locale in Locale::ALL {
for source in
["None", "New album", "New album…", "Imported Photos", "Choose an album…", "Search albums", "No albums yet", "No albums match"]
{
assert!(!locale.tr(source).is_empty());
if *locale != Locale::En {
assert_ne!(locale.tr(source), source, "{locale:?}: {source}");
}
}
let r = h.request("ui.set", json!({"language": locale.code()}), T); let r = h.request("ui.set", json!({"language": locale.code()}), T);
assert_eq!(r["ok"], true, "{r}"); assert_eq!(r["ok"], true, "{r}");
assert!(h.settle(SETTLE)); assert!(h.settle(SETTLE));
+3 -2
View File
@@ -46,8 +46,9 @@ ani anglické zdrojové řetězce. Rozložení panelů a seznamu jazyků je beze
## Ověření a revize ## Ověření a revize
Testy `i18n::tests` ověřují registraci příkazu, přepínání přes nabídku a řídicí kanál, Testy `i18n::tests` ověřují registraci příkazu, přepínání přes nabídku a řídicí kanál,
uložení jazyka, pokrytí katalogů, placeholdery, neutrální formulace počtů, datum, čas uložení jazyka, placeholdery, datum, čas a české glyfy. Překladové šablony kontroluje také
a české glyfy. Překladové šablony kontroluje také Rust při sestavení. Rust při sestavení. Chybějící překlady se zobrazují anglicky a vypisuje je
`i18n::tests::translation_gaps_report`; žádný test kvůli nim neselže.
Při jazykové korektuře doporučujeme zkontrolovat „asistované třídění“, „sada“ pro Stack, Při jazykové korektuře doporučujeme zkontrolovat „asistované třídění“, „sada“ pro Stack,
„bodová barva“, „měkký nátisk“ a názvy kreativních přednastavení. „bodová barva“, „měkký nátisk“ a názvy kreativních přednastavení.
+3 -3
View File
@@ -29,8 +29,8 @@ cargo test -p lightcraft-ui-egui i18n::tests
LIGHTCRAFT_LANGUAGE=de lightcraft-cli snapshot --demo -o deutsch.png --size 1600x1000 LIGHTCRAFT_LANGUAGE=de lightcraft-cli snapshot --demo -o deutsch.png --size 1600x1000
``` ```
Die Tests prüfen Katalogabdeckung, Platzhalter, Sprachwechsel per Menü und Steuerkanal, Die Tests prüfen Platzhalter, Sprachwechsel per Menü und Steuerkanal, Speicherung,
Speicherung, Datumsformate, unveränderte Benutzernamen und gerenderte Texte sowie Umlaute Datumsformate, unveränderte Benutzernamen und gerenderte Texte sowie Umlaute und ß. Fehlende
und ß. Ein weiterer Render-Test prüft die Schaltflächenbreiten; der Importtest prüft Übersetzungen erscheinen auf Englisch; `i18n::tests::translation_gaps_report` listet sie auf. Ein weiterer Render-Test prüft die Schaltflächenbreiten; der Importtest prüft
auch den Sprachwechsel nach der Hintergrundprüfung der Quellen. Akzeptierte Sprachangaben umfassen `de`, `de-DE`, `de_AT.UTF-8` und `de-CH`; auch den Sprachwechsel nach der Hintergrundprüfung der Quellen. Akzeptierte Sprachangaben umfassen `de`, `de-DE`, `de_AT.UTF-8` und `de-CH`;
gespeichert wird stets `de`. Weitere Hinweise stehen in [localization.md](localization.md). gespeichert wird stets `de`. Weitere Hinweise stehen in [localization.md](localization.md).
+3 -2
View File
@@ -52,8 +52,9 @@ englanniksi. Käännös ei muuta kirjastotietoja eikä englanninkielisiä lähde
## Testaus ## Testaus
`i18n::tests` tarkistaa komennon rekisteröinnin, vaihdon valikosta ja ohjauskanavasta, `i18n::tests` tarkistaa komennon rekisteröinnin, vaihdon valikosta ja ohjauskanavasta,
kielen tallennuksen, luetteloiden kattavuuden, paikkamerkit, määrämuodot, päivämäärän, ajan ja kielen tallennuksen, paikkamerkit, päivämäärän, ajan ja suomen kirjaimet. Rust tarkistaa
suomen kirjaimet. Rust tarkistaa muotoilumallit käännösvaiheessa. muotoilumallit käännösvaiheessa. Puuttuvat käännökset näkyvät englanniksi, ja
`i18n::tests::translation_gaps_report` luettelee ne; mikään testi ei epäonnistu niiden takia.
Kielentarkistuksessa kannattaa katsoa erityisesti: ”Väriluokittelu” (Color Grading), Kielentarkistuksessa kannattaa katsoa erityisesti: ”Väriluokittelu” (Color Grading),
”Valoalueet / Valkoiset” (Highlights / Whites), ”Näyttövedos” (Soft Proofing), ”Valoalueet / Valkoiset” (Highlights / Whites), ”Näyttövedos” (Soft Proofing),
+4 -4
View File
@@ -19,7 +19,7 @@ not translated. Descriptions for SAM 3 go to the model unchanged.
## Georgian script ## Georgian script
Mkhedruli has no upper and lower case: `i18n::upper_first` leaves Georgian letters alone, and the Mkhedruli has no upper and lower case: `i18n::upper_first` leaves Georgian letters alone, and the
interface never uses Mtavruli (a test checks the whole catalog). Commands are verbal nouns. Counts interface never uses Mtavruli. Commands are verbal nouns. Counts
are count-neutral, noun first (`იმპორტირებულია ფოტო: 3`); English plural endings are dropped with are count-neutral, noun first (`იმპორტირებულია ფოტო: 3`); English plural endings are dropped with
`{:.0}`. Dates read `კვირა, 20.09.2026` (`{weekday}, {day:02}.{month:02}.{year}`), months `09.2026`. `{:.0}`. Dates read `კვირა, 20.09.2026` (`{weekday}, {day:02}.{month:02}.{year}`), months `09.2026`.
@@ -73,7 +73,7 @@ cargo test -p lightcraft-ui-egui i18n::tests -- --nocapture
LIGHTCRAFT_LANGUAGE=ka lightcraft-cli snapshot --demo -o georgian.png --size 1600x1000 LIGHTCRAFT_LANGUAGE=ka lightcraft-cli snapshot --demo -o georgian.png --size 1600x1000
``` ```
The tests check catalog coverage, placeholders, switching language by menu and control channel, The tests check placeholders, switching language by menu and control channel, saving, the `ka`
saving, the `ka` locale tags, dates, count wording, the absence of Mtavruli and that sentence case locale tags, dates and that sentence case keeps Georgian lowercase. Messages Georgian lacks show in
keeps Georgian lowercase. More notes are in English; `i18n::tests::translation_gaps_report` lists them. More notes are in
[localization.md](localization.md). [localization.md](localization.md).
+3 -2
View File
@@ -66,8 +66,9 @@ LIGHTCRAFT_LANGUAGE=vi lightcraft-cli snapshot --demo -o vi.png --size 1600x1000
LIGHTCRAFT_LANGUAGE=vi lightcraft-cli snapshot --demo -o vi-narrow.png --size 1280x800 LIGHTCRAFT_LANGUAGE=vi lightcraft-cli snapshot --demo -o vi-narrow.png --size 1280x800
``` ```
Các test tiếng Việt bắt buộc phủ catalog union, nhãn lệnh/control/Settings/Smart Album, Các test tiếng Việt kiểm tra đổi ngôn ngữ, lưu `vi`, formats, ngày, dữ liệu người dùng và glyph
đổi ngôn ngữ, lưu `vi`, formats, ngày, dữ liệu người dùng, và glyph dấu. dấu. Chuỗi còn thiếu hiện bằng tiếng Anh và được `i18n::tests::translation_gaps_report` liệt kê;
không test nào thất bại vì thiếu bản dịch.
## Nguồn dịch (clean-room) ## Nguồn dịch (clean-room)
+2 -1
View File
@@ -34,5 +34,6 @@ LightCraft 的界面支持英语、简体中文、繁体中文(台湾)和日
`LIGHTCRAFT_LANGUAGE=zh-hans lightcraft-cli snapshot ...` 可以渲染中文界面用于检查。 `LIGHTCRAFT_LANGUAGE=zh-hans lightcraft-cli snapshot ...` 可以渲染中文界面用于检查。
翻译是否完整、两种目录的键与占位符是否一致、字形是否齐全,都由 两种目录的占位符是否一致、字形是否齐全,由
`cargo test -p lightcraft-ui-egui i18n::tests` 验证(字形检查只在指定 `CRAFT_FONTS_DIR` 时实际执行)。 `cargo test -p lightcraft-ui-egui i18n::tests` 验证(字形检查只在指定 `CRAFT_FONTS_DIR` 时实际执行)。
缺少的译文显示为英文,由 `i18n::tests::translation_gaps_report` 列出,不会让测试失败。
+16 -12
View File
@@ -12,11 +12,10 @@ are in [`localization-zh-hans.md`](localization-zh-hans.md), [`localization-zh-h
1. **Translate the two catalogs.** Copy `locales/ja.json` and `locales/ja-formats.json` to 1. **Translate the two catalogs.** Copy `locales/ja.json` and `locales/ja-formats.json` to
`locales/<code>.json` and `locales/<code>-formats.json`, then translate the values. Keys are the `locales/<code>.json` and `locales/<code>-formats.json`, then translate the values. Keys are the
English source strings and must stay byte-for-byte identical. The plain catalog may grow at its English source strings and must stay byte-for-byte identical. Both catalogs may grow at their
own pace: a message it lacks shows in English, and `cargo test -p lightcraft-ui-egui i18n::tests own pace: a message a catalog lacks, or translates as an empty string, shows in English, and
-- --nocapture` lists the gaps (it fails on an empty entry or mismatched placeholders). The nothing fails. The translation gaps report lists what each language lacks (see
formats catalog must carry every message (the build fails otherwise; a message copied in English, [Testing and looking at it](#testing-and-looking-at-it)). The formats catalog also holds the date
value equal to its key, is an untranslated placeholder until someone translates it), including the date
patterns (`{year}`, `{month} {year}`, `{weekday}, {day} {month} {year}`…) that date headings and patterns (`{year}`, `{month} {year}`, `{weekday}, {day} {month} {year}`…) that date headings and
capture times use in every language but English; weekday names go in the plain catalog. capture times use in every language but English; weekday names go in the plain catalog.
2. **Add the table entry** in `crates/ui-egui/src/i18n.rs`: 2. **Add the table entry** in `crates/ui-egui/src/i18n.rs`:
@@ -81,18 +80,23 @@ tr_format!("Added {n} photo{} to “{}”", if n == 1 { "" } else { "s" }, album
`"已将 {n} 张照片添加到“{1}”{0:.0}"`. Keep the source's spec (`{:.1}`, `{d:+}`, `{:.0}%`). `"已将 {n} 张照片添加到“{1}”{0:.0}"`. Keep the source's spec (`{:.1}`, `{d:+}`, `{:.0}%`).
- `format!` rejects an unused argument, so a value a language does not need — the English plural - `format!` rejects an unused argument, so a value a language does not need — the English plural
suffix (`"s"` / `""`) — is consumed as `{:.0}` (a string at precision 0 prints nothing). suffix (`"s"` / `""`) — is consumed as `{:.0}` (a string at precision 0 prints nothing).
- Every formats catalog carries the same messages (`build.rs` fails the build otherwise), and a - A language whose formats catalog lacks a message shows the English source there, and a
`tr_format!` call whose message is in no catalog does not compile: add it to every `tr_format!` call whose message is in no catalog formats the English source (the macro's last
`*-formats.json` file. arm). A new message builds with no catalog entry.
## Testing and looking at it ## Testing and looking at it
```sh ```sh
cargo test -p lightcraft-ui-egui i18n::tests # catalogs, keys, placeholders, commands cargo test -p lightcraft-ui-egui i18n::tests # catalogs load, placeholders, switching
CRAFT_FONTS_DIR=../craft-fonts cargo test -p lightcraft-ui-egui i18n::tests # + glyph coverage CRAFT_FONTS_DIR=../craft-fonts cargo test -p lightcraft-ui-egui i18n::tests # + glyph coverage
cargo test -p lightcraft-ui-egui --lib -- --exact i18n::tests::translation_gaps_report --nocapture
``` ```
The glyph tests are skipped without `CRAFT_FONTS_DIR` (there are no CJK faces to check then). The tests fail on a broken catalog (malformed JSON, a repeated key, placeholders that differ from
the source), never on a missing translation. The last command is the translation gaps report: per
language, the count and list of plain and format messages it lacks and the number of untranslated
What's New lines. It always passes, and `cargo xtask ci` prints it after its test step. The glyph
tests are skipped without `CRAFT_FONTS_DIR` (there are no CJK faces to check then).
To look at a language, render it headless: To look at a language, render it headless:
```sh ```sh
@@ -114,5 +118,5 @@ when a region has no dedicated entry.
Help ▸ What's New shows `docs/whats-new.md`, and each of its lines (a heading, or one wrapped line of a Help ▸ What's New shows `docs/whats-new.md`, and each of its lines (a heading, or one wrapped line of a
bullet, without the `- ` or `### `) is looked up like any message, so a catalog can translate the notes bullet, without the `- ` or `### `) is looked up like any message, so a catalog can translate the notes
line by line (German does). A line that is re-wrapped or edited no longer matches its translation and line by line (German does). A line that is re-wrapped or edited no longer matches its translation and
shows in English until the catalog is updated; untranslated lines are reported by shows in English until the catalog is updated; the translation gaps report counts the untranslated
`display_label_gaps_are_reported`, never failed. lines.
+36 -1
View File
@@ -37,7 +37,8 @@ commands:
ci [--hardware] [--summary-md] ci [--hardware] [--summary-md]
fmt --check, clippy -D warnings, heif, test, GPU probe, parity refs, layers, assets, wasm fmt --check, clippy -D warnings, heif, test, GPU probe, parity refs, layers, assets, wasm
(stops at first failure). Tests run under cargo test (cargo nextest with (stops at first failure). Tests run under cargo test (cargo nextest with
LIGHTCRAFT_NEXTEST=1), without examples, then doctests with cargo test --doc. Writes LIGHTCRAFT_NEXTEST=1), without examples, then doctests with cargo test --doc; after
them it prints the translation gaps report (never a failure). Writes
target/ci/report.json (machine, GPU adapters, data, target/ci/report.json (machine, GPU adapters, data,
steps, skipped tests) and target/ci/summary.md; --hardware also runs the GPU tests on steps, skipped tests) and target/ci/summary.md; --hardware also runs the GPU tests on
every backend with a hardware adapter; --summary-md prints the Markdown summary every backend with a hardware adapter; --summary-md prints the Markdown summary
@@ -238,6 +239,39 @@ fn doctests() -> (Command, String) {
(c, "cargo test --workspace --doc".to_owned()) (c, "cargo test --workspace --doc".to_owned())
} }
/// The test that prints the messages each language lacks (shown in English); it always passes.
const TRANSLATION_GAPS_TEST: &str = "i18n::tests::translation_gaps_report";
/// Print the translation gaps report into the log: [`TRANSLATION_GAPS_TEST`] of lightcraft-ui-egui,
/// run from the test binary the test step built (found with `--no-run` and the step's own
/// arguments, so nothing rebuilds). A report that can't run is a note, never a failed step.
fn translation_gaps() {
eprintln!("\n=== ci: translation gaps ===");
let mut c = cargo();
c.args(["test", "--no-run", "--message-format=json"]).args(with_test_targets(&["--workspace"]));
let binary = c.output().map_err(|e| format!("cargo test --no-run: {e}")).and_then(|out| {
String::from_utf8_lossy(&out.stdout)
.lines()
.filter_map(|line| serde_json::from_str::<serde_json::Value>(line).ok())
.find(|m| {
m["reason"] == "compiler-artifact"
&& m["target"]["name"] == "lightcraft_ui_egui"
&& m["profile"]["test"] == true
&& m["target"]["kind"].as_array().is_some_and(|kinds| kinds.iter().any(|kind| kind == "lib"))
})
.and_then(|m| m["executable"].as_str().map(PathBuf::from))
.ok_or_else(|| format!("no lightcraft-ui-egui test binary in the build (cargo exited with {})", out.status))
});
let ran = binary.and_then(|binary| {
let mut c = Command::new(&binary);
c.args(["--exact", TRANSLATION_GAPS_TEST, "--nocapture", "--quiet"]);
run(c, &format!("{} --exact {TRANSLATION_GAPS_TEST} --nocapture --quiet", binary.display()))
});
if let Err(e) = ran {
eprintln!("note: the translation gaps report did not run: {e}");
}
}
pub fn run(mut cmd: Command, what: &str) -> Result<(), String> { pub fn run(mut cmd: Command, what: &str) -> Result<(), String> {
eprintln!("$ {what}"); eprintln!("$ {what}");
let status = cmd.status().map_err(|e| format!("{what}: failed to spawn: {e}"))?; let status = cmd.status().map_err(|e| format!("{what}: failed to spawn: {e}"))?;
@@ -431,6 +465,7 @@ fn cmd_ci(args: &[&str]) -> Result<(), String> {
if *name == "test" { if *name == "test" {
// right after the workspace tests, before parity and wasm build other feature sets // right after the workspace tests, before parity and wasm build other feature sets
probe(); probe();
translation_gaps();
} }
if let Err(e) = r { if let Err(e) = r {
failure = Some((*name, e)); failure = Some((*name, e));