mirror of
https://github.com/storytold/gridcraft.git
synced 2026-10-26 03:36:54 -06:00
i18n: missing translations are reported, never a failure (#2383)
A string a catalog lacks shows in English. Coverage, key-parity and per-language count tests and tests of one language's exact wording are removed; UI wiring tests take their expected text from the catalog. cargo xtask i18n-report lists the missing strings per language and always passes; cargo xtask ci runs it first.
This commit is contained in:
@@ -1084,11 +1084,8 @@ mod tests {
|
||||
}
|
||||
assert!(age(Language::En, f64::MAX).ends_with(" years ago"));
|
||||
// Other languages put the count where their own words want it.
|
||||
assert_eq!(age(Language::Ja, 3.0 * DAY), "3 日前");
|
||||
assert_eq!(age(Language::De, 3.0 * DAY), "vor 3 Tagen");
|
||||
assert_eq!(age(Language::Fr, 90.0), "il y a 1 min");
|
||||
assert_eq!(age(Language::It, 3.0 * DAY), "3 giorni fa");
|
||||
for lang in Language::ALL {
|
||||
assert!(age(lang, 3.0 * DAY).contains('3'), "{lang:?}: {}", age(lang, 3.0 * DAY));
|
||||
for seconds in [10.0, 600.0, 7200.0, 1.5 * DAY, 5.0 * DAY, 30.0 * DAY, 200.0 * DAY, 2000.0 * DAY] {
|
||||
let words = age(lang, seconds);
|
||||
assert!(!words.is_empty() && !words.contains('{'), "{lang:?} {seconds}: {words}");
|
||||
|
||||
@@ -105,13 +105,6 @@ fn split_signature(signature: &str) -> Option<(&str, Vec<&str>)> {
|
||||
Some((head, if inner.is_empty() { Vec::new() } else { inner.split(", ").collect() }))
|
||||
}
|
||||
|
||||
/// The catalog names of a signature's arguments (`number` for `[number2]`), without `...`; the
|
||||
/// function help tables are checked against them.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn argument_names(signature: &str) -> Vec<&str> {
|
||||
split_signature(signature).map(|(_, args)| args.into_iter().map(argument_name).filter(|name| *name != "...").collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// `[number2]` -> `number`. A bracket can open or close on its own (SWITCH's
|
||||
/// `[default_or_value2, result2]`, LAMBDA's `[parameter1, ...]`).
|
||||
fn argument_name(arg: &str) -> &str {
|
||||
|
||||
+19
-191
@@ -321,21 +321,14 @@ fn name_sort_key(name: &str) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Help tables are sorted, unique, and keyed by text the function catalog still has.
|
||||
/// Help tables are sorted and unique: lookups binary-search them.
|
||||
#[test]
|
||||
fn function_help_is_sorted_and_keyed_by_current_catalog_text() {
|
||||
// Lookups binary-search the tables. A key that no longer matches the catalog (an English
|
||||
// description reworded, an argument renamed) would quietly show English again.
|
||||
let catalog = gridcraft_engine::cmd::formulas::function_list();
|
||||
let descriptions: std::collections::HashSet<&str> = catalog.iter().filter_map(|f| f["description"].as_str()).collect();
|
||||
let arguments: std::collections::HashSet<&str> =
|
||||
catalog.iter().filter_map(|f| f["signature"].as_str()).flat_map(crate::formula_locale::argument_names).collect();
|
||||
fn function_help_tables_are_sorted_and_unique() {
|
||||
for language in Language::ALL {
|
||||
let Some((description_table, argument_table)) = language.function_help() else { continue };
|
||||
for (table, catalog_text) in [(description_table, &descriptions), (argument_table, &arguments)] {
|
||||
for table in [description_table, argument_table] {
|
||||
assert!(table.windows(2).all(|pair| pair[0].0 < pair[1].0), "{language:?} function help is not sorted and unique");
|
||||
for (english, translated) in table {
|
||||
assert!(catalog_text.contains(english), "{language:?} translates {english:?}, which the function catalog doesn't use");
|
||||
assert!(!translated.is_empty(), "{language:?}: empty translation of {english:?}");
|
||||
}
|
||||
}
|
||||
@@ -343,105 +336,27 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn translations_are_unique_and_preserve_unknown_text() {
|
||||
for (i, (en, ja)) in ja::CORE.iter().enumerate() {
|
||||
assert!(!ja.is_empty());
|
||||
assert!(ja::CORE.iter().take(i).all(|(other, _)| en != other));
|
||||
assert_eq!(Language::En.tr(en), en.rsplit_once('|').map_or(*en, |(_, label)| label));
|
||||
}
|
||||
fn untranslated_text_falls_back_to_english() {
|
||||
let label = |key: &'static str| key.rsplit_once('|').map_or(key, |(_, label)| label);
|
||||
for (english, _) in ja::CORE {
|
||||
assert!(zh::translate(english).is_some_and(|text| !text.is_empty()), "missing Simplified Chinese: {english}");
|
||||
assert!(ko::translate(english).is_some_and(|text| !text.is_empty()), "missing Korean: {english}");
|
||||
assert!(ru::translate(english).is_some_and(|text| !text.is_empty()), "missing Russian: {english}");
|
||||
assert!(pt::translate(english).is_some_and(|text| !text.is_empty()), "missing Portuguese: {english}");
|
||||
assert!(es::translate(english).is_some_and(|text| !text.is_empty()), "missing Spanish: {english}");
|
||||
assert!(el::translate(english).is_some_and(|text| !text.is_empty()), "missing Greek: {english}");
|
||||
assert!(de::translate(english).is_some_and(|text| !text.is_empty()), "missing German: {english}");
|
||||
assert!(cs::translate(english).is_some_and(|text| !text.is_empty()), "missing Czech: {english}");
|
||||
assert!(fr::translate(english).is_some_and(|text| !text.is_empty()), "missing French: {english}");
|
||||
assert!(sr::translate(english).is_some_and(|text| !text.is_empty()), "missing Serbian: {english}");
|
||||
assert!(tr::translate(english).is_some_and(|text| !text.is_empty()), "missing Turkish: {english}");
|
||||
assert!(pl::translate(english).is_some_and(|text| !text.is_empty()), "missing Polish: {english}");
|
||||
assert!(it::translate(english).is_some_and(|text| !text.is_empty()), "missing Italian: {english}");
|
||||
assert!(uk::translate(english).is_some_and(|text| !text.is_empty()), "missing Ukrainian: {english}");
|
||||
assert!(vi::translate(english).is_some_and(|text| !text.is_empty()), "missing Vietnamese: {english}");
|
||||
assert!(nl::translate(english).is_some_and(|text| !text.is_empty()), "missing Dutch: {english}");
|
||||
assert!(ro::translate(english).is_some_and(|text| !text.is_empty()), "missing Romanian: {english}");
|
||||
assert_eq!(Language::En.tr(english), label(english));
|
||||
}
|
||||
assert_eq!(Language::Ja.tr("Data"), "データ");
|
||||
assert_eq!(Language::Zh.tr("Data"), "数据");
|
||||
assert_eq!(Language::Ko.tr("Data"), "데이터");
|
||||
assert_eq!(Language::Ru.tr("Data"), "Данные");
|
||||
assert_eq!(Language::PtBr.tr("Data"), "Dados");
|
||||
assert_eq!(Language::PtBr.tr_error("Select a chart first."), "Selecione um gráfico primeiro.");
|
||||
assert_eq!(Language::PtBr.tr_error("Sheet|Unknown failure"), "Sheet|Unknown failure");
|
||||
assert_eq!(Language::PtBr.tr_error("command `chart`: Select a chart first."), "Selecione um gráfico primeiro.");
|
||||
assert_eq!(Language::PtBr.tr_error("Printer Office: permission denied"), "Printer Office: permission denied");
|
||||
assert_eq!(Language::Es.tr("Data"), "Datos");
|
||||
assert_eq!(Language::El.tr("Data"), "Δεδομένα");
|
||||
assert_eq!(Language::El.tr("Page Layout|Orientation"), "Προσανατολισμός σελίδας");
|
||||
assert_eq!(Language::El.tr("Sheet1!A1"), "Sheet1!A1");
|
||||
assert_eq!(Language::El.tr("Page {page} of {pages}"), "Σελίδα {page} από {pages}");
|
||||
assert_eq!(Language::Es.tr("Freeze Panes"), "Inmovilizar paneles");
|
||||
assert_eq!(Language::Es.tr("Sheet1!A1"), "Sheet1!A1");
|
||||
assert_eq!(Language::De.tr("Data"), "Daten");
|
||||
assert_eq!(Language::De.tr("Sheet1!A1"), "Sheet1!A1");
|
||||
assert_eq!(Language::Fr.tr("Data"), "Données");
|
||||
assert_eq!(Language::Fr.tr("Sheet1!A1"), "Sheet1!A1");
|
||||
assert_eq!(Language::Tr.tr("Data"), "Veri");
|
||||
assert_eq!(Language::Tr.tr("Sheet1!A1"), "Sheet1!A1");
|
||||
assert_eq!(Language::PtBr.tr("Date"), "Data");
|
||||
assert_eq!(Language::PtBr.tr("Sheet1!A1"), "Sheet1!A1");
|
||||
assert_eq!(Language::Ja.tr("Sheet1!A1"), "Sheet1!A1");
|
||||
assert_eq!(Language::Zh.tr("Sheet1!A1"), "Sheet1!A1");
|
||||
assert_eq!(Language::Ko.tr("Sheet1!A1"), "Sheet1!A1");
|
||||
assert_eq!(Language::Ru.tr("Sheet1!A1"), "Sheet1!A1");
|
||||
// Czech "Data" is spelled as in English, so probe it with a label that differs.
|
||||
assert_eq!(Language::Cs.tr("Data"), "Data");
|
||||
assert_eq!(Language::Cs.tr("Formulas"), "Vzorce");
|
||||
assert_eq!(Language::Cs.tr("Sheet1!A1"), "Sheet1!A1");
|
||||
assert_eq!(Language::Tr.tr("Formulas"), "Formüller");
|
||||
// A `context|label` key shows only its label in English and splits the translation.
|
||||
for language in Language::ALL {
|
||||
assert_eq!(language.tr("Sheet1!A1"), "Sheet1!A1");
|
||||
assert_eq!(language.tr("Not in any catalog|Untranslated"), "Untranslated");
|
||||
}
|
||||
// A `context|label` key shows only its label in English.
|
||||
assert_eq!(Language::En.tr("Page Layout|Orientation"), "Orientation");
|
||||
assert_eq!(Language::Ja.tr("Page Layout|Orientation"), "印刷の向き");
|
||||
assert_eq!(Language::Ja.tr("Orientation"), "方向");
|
||||
assert_eq!(Language::Cs.tr("Page Layout|Orientation"), "Orientace");
|
||||
assert_eq!(Language::Cs.tr("Orientation"), "Orientace textu");
|
||||
assert_eq!(Language::Sr.tr("Data"), "Podaci");
|
||||
assert_eq!(Language::Sr.tr("Sheet1!A1"), "Sheet1!A1");
|
||||
assert_eq!(Language::Sr.tr("Page Layout|Orientation"), "Orijentacija");
|
||||
assert_eq!(Language::Sr.tr("Orientation"), "Orijentacija");
|
||||
assert_eq!(Language::Tr.tr("Page Layout|Orientation"), "Sayfa Yönü");
|
||||
assert_eq!(Language::Tr.tr("Orientation"), "Yönlendirme");
|
||||
assert_eq!(Language::Pl.tr("Data"), "Dane");
|
||||
assert_eq!(Language::Pl.tr("Sheet1!A1"), "Sheet1!A1");
|
||||
assert_eq!(Language::Pl.tr("Page Layout|Orientation"), "Orientacja");
|
||||
assert_eq!(Language::It.tr("Data"), "Dati");
|
||||
assert_eq!(Language::It.tr("Sheet1!A1"), "Sheet1!A1");
|
||||
assert_eq!(Language::It.tr("Freeze Panes"), "Blocca riquadri");
|
||||
assert_eq!(Language::It.tr("Page Layout|Orientation"), "Orientamento");
|
||||
assert_eq!(Language::It.tr("Orientation"), "Orientamento");
|
||||
assert_eq!(Language::Uk.tr("Data"), "Дані");
|
||||
assert_eq!(Language::Uk.tr("Sheet1!A1"), "Sheet1!A1");
|
||||
assert_eq!(Language::Vi.tr("Data"), "Dữ liệu");
|
||||
assert_eq!(Language::Vi.tr("Sheet1!A1"), "Sheet1!A1");
|
||||
assert_eq!(Language::Vi.tr("Page Layout|Orientation"), "Hướng giấy");
|
||||
assert_eq!(Language::Vi.tr("Orientation"), "Hướng chữ");
|
||||
assert_eq!(Language::Nl.tr("Data"), "Gegevens");
|
||||
assert_eq!(Language::Nl.tr("Sheet1!A1"), "Sheet1!A1");
|
||||
assert_eq!(Language::Nl.tr("Page Layout|Orientation"), "Paginastand");
|
||||
assert_eq!(Language::Nl.tr("Orientation"), "Richting");
|
||||
assert_eq!(Language::Ro.tr("Data"), "Date");
|
||||
assert_eq!(Language::Ro.tr("Sheet1!A1"), "Sheet1!A1");
|
||||
assert_eq!(Language::Ro.tr("Page Layout|Orientation"), "Orientare");
|
||||
assert_eq!(Language::Ro.tr("Orientation"), "Orientare text");
|
||||
// Errors drop the `command `id`:` prefix before translating; an untranslated one stays whole.
|
||||
assert_eq!(Language::PtBr.tr_error("command `chart`: Select a chart first."), Language::PtBr.tr("Select a chart first."));
|
||||
assert_eq!(Language::PtBr.tr_error("Sheet|Unknown failure"), "Sheet|Unknown failure");
|
||||
assert_eq!(Language::PtBr.tr_error("Printer Office: permission denied"), "Printer Office: permission denied");
|
||||
assert_eq!(Language::parse("xx"), None);
|
||||
}
|
||||
|
||||
/// A catalog whose translations slip onto their neighbours' keys still covers every key, so
|
||||
/// check the shape of each translation against its English: placeholders survive, and a
|
||||
/// one-line label (a menu item, a tooltip) doesn't turn into a two-line ribbon label. French
|
||||
/// once shipped with over a hundred translations shifted this way ("Close" read "Société").
|
||||
/// one-line label (a menu item, a tooltip) doesn't turn into a two-line ribbon label.
|
||||
#[test]
|
||||
fn translations_keep_the_shape_of_their_english() {
|
||||
let placeholders =
|
||||
@@ -457,106 +372,19 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
for (english, french) in [
|
||||
("Close", "Fermer"),
|
||||
("File", "Fichier"),
|
||||
("Save", "Enregistrer"),
|
||||
("Animations", "Animations"),
|
||||
("Company", "Société"),
|
||||
("Show Detail", "Afficher le détail"),
|
||||
("Solver", "Solveur"),
|
||||
] {
|
||||
assert_eq!(Language::Fr.tr(english), french);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn german_cell_and_header_context_menus_are_fully_translated() {
|
||||
for label in [
|
||||
"Cut",
|
||||
"Copy",
|
||||
"Paste",
|
||||
"Paste Special…",
|
||||
"Insert…",
|
||||
"Delete…",
|
||||
"Clear Contents",
|
||||
"Filter by Selected Cell's Value",
|
||||
"Sort A to Z",
|
||||
"Sort Z to A",
|
||||
"New Comment",
|
||||
"New Note",
|
||||
"Format Cells…",
|
||||
"Pick From Drop-down List…",
|
||||
"Define Name…",
|
||||
"Insert Copied Cells",
|
||||
"Insert Copied Cells…",
|
||||
"Insert Cut Cells",
|
||||
"Insert",
|
||||
"Delete",
|
||||
"Row Height…",
|
||||
"AutoFit Row Height",
|
||||
"Column Width…",
|
||||
"AutoFit Column Width",
|
||||
"Hide",
|
||||
"Unhide",
|
||||
"Group",
|
||||
] {
|
||||
assert_ne!(Language::De.tr(label), label, "German context menu label missing: {label}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sheet_tab_menu_and_autosave_labels_are_translated() {
|
||||
let labels = [
|
||||
"Insert Sheet",
|
||||
"Delete",
|
||||
"Rename",
|
||||
"Move or Copy…",
|
||||
"Duplicate",
|
||||
"Tab Color",
|
||||
"No Color",
|
||||
"Hide",
|
||||
"Unhide…",
|
||||
"Protect Sheet…",
|
||||
"Unprotect Sheet…",
|
||||
"Select All Sheets",
|
||||
"AutoSave",
|
||||
"AutoSave: save after every change (workbooks saved to a file)",
|
||||
"Previous sheet",
|
||||
"Next sheet",
|
||||
"All sheets",
|
||||
"New sheet (⇧F11)",
|
||||
];
|
||||
for language in [Language::Es, Language::De, Language::Fr, Language::Ko, Language::Ru, Language::Zh, Language::Sr] {
|
||||
for label in labels {
|
||||
assert_ne!(language.tr(label), label, "{} lacks a translation of {label:?}", language.code());
|
||||
}
|
||||
}
|
||||
assert_eq!(Language::Es.tr("AutoSave"), "Autoguardado");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn translations_never_show_the_context_prefix() {
|
||||
// `tr` returns a found translation as it is, so a catalog that keeps `context|` in its
|
||||
// value shows it on screen (File › Options read "Options|Général" in French).
|
||||
for language in Language::ALL {
|
||||
for (english, _) in ja::CORE {
|
||||
let keys = ja::CORE.iter().map(|(english, _)| *english).chain(["Options|General", "Options|Save", "Sheet Options|View"]);
|
||||
for english in keys {
|
||||
for language in Language::ALL {
|
||||
let shown = language.tr(english);
|
||||
assert!(!shown.contains('|'), "{} shows a context prefix for {english}: {shown}", language.code());
|
||||
}
|
||||
}
|
||||
assert_eq!(Language::Fr.tr("Options|General"), "Général");
|
||||
assert_eq!(Language::Fr.tr("Options|Save"), "Enregistrer");
|
||||
assert_eq!(Language::Fr.tr("Sheet Options|View"), "Affichage");
|
||||
assert_eq!(Language::Sr.tr("Options|General"), "Opšte");
|
||||
assert_eq!(Language::Sr.tr("Options|Save"), "Sačuvaj");
|
||||
assert_eq!(Language::Sr.tr("Sheet Options|View"), "Prikaz");
|
||||
assert_eq!(Language::Nl.tr("Options|General"), "Algemeen");
|
||||
assert_eq!(Language::Nl.tr("Options|Save"), "Opslaan");
|
||||
assert_eq!(Language::Nl.tr("Sheet Options|View"), "Weergeven");
|
||||
assert_eq!(Language::Ro.tr("Options|General"), "General");
|
||||
assert_eq!(Language::Ro.tr("Options|Save"), "Salvare");
|
||||
assert_eq!(Language::Ro.tr("Sheet Options|View"), "Vizualizare");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
//! Japanese translations for the interface strings in `i18n.rs`; labels follow Excel for Mac's
|
||||
//! Japanese wording, and anything absent falls back to English.
|
||||
//!
|
||||
//! [`CORE`] is the shared reference catalog every language translates: the ribbon, sheet tabs,
|
||||
//! widgets and the most common dialog labels. [`DIALOGS`] adds the dialog titles and labels,
|
||||
//! message boxes, Backstage/File-view items, template names and native-menu strings the Brazilian
|
||||
//! Portuguese catalog (`pt.rs`) covers on top of it. The two lists never overlap.
|
||||
//! [`CORE`] is the shared reference set: the ribbon, sheet tabs, widgets and the most common dialog
|
||||
//! labels. [`DIALOGS`] adds the dialog titles and labels, message boxes, Backstage/File-view items,
|
||||
//! template names and native-menu strings. The two lists never overlap. A string a language doesn't
|
||||
//! translate shows in English; `cargo xtask i18n-report` lists them per language.
|
||||
|
||||
/// English label → Japanese, the shared reference set every catalog covers.
|
||||
/// English label → Japanese, the shared reference set.
|
||||
pub(super) const CORE: &[(&str, &str)] = &[
|
||||
("Paste", "貼り付け"),
|
||||
("Paste Values", "値の貼り付け"),
|
||||
@@ -1150,99 +1150,17 @@ mod tests {
|
||||
DIALOGS.iter().chain(CORE).map(|(english, _)| *english)
|
||||
}
|
||||
|
||||
/// The decoded value of the Rust string literal starting at byte `start` (its opening `"`), and
|
||||
/// the byte index just past its closing quote. Escapes (`\n`, `\"`, `\\`, `\t`, `\u{…}`) are
|
||||
/// decoded, so the keys compare equal to the ones in the tables above.
|
||||
fn string_literal(source: &str, start: usize) -> Option<(String, usize)> {
|
||||
if !source[start..].starts_with('"') {
|
||||
return None;
|
||||
}
|
||||
let mut value = String::new();
|
||||
let mut chars = source[start + 1..].char_indices();
|
||||
while let Some((offset, c)) = chars.next() {
|
||||
match c {
|
||||
'"' => return Some((value, start + 1 + offset + 1)),
|
||||
'\\' => match chars.next().map(|(_, c)| c) {
|
||||
Some('n') => value.push('\n'),
|
||||
Some('t') => value.push('\t'),
|
||||
Some('r') => value.push('\r'),
|
||||
Some('0') => value.push('\0'),
|
||||
Some('\\') => value.push('\\'),
|
||||
Some('"') => value.push('"'),
|
||||
Some('\'') => value.push('\''),
|
||||
Some('u') => {
|
||||
let mut hex = String::new();
|
||||
for (_, c) in chars.by_ref() {
|
||||
if c == '}' {
|
||||
break;
|
||||
}
|
||||
if c != '{' {
|
||||
hex.push(c);
|
||||
}
|
||||
}
|
||||
if let Some(c) = u32::from_str_radix(&hex, 16).ok().and_then(char::from_u32) {
|
||||
value.push(c);
|
||||
}
|
||||
}
|
||||
// A line continuation (backslash, newline, indentation) adds nothing.
|
||||
_ => {}
|
||||
},
|
||||
_ => value.push(c),
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Every key `pt.rs` translates, read out of its source with a small scanner: a match arm is a
|
||||
/// string literal whose `=>` is followed by another string literal, which covers the block form
|
||||
/// (`"key" => { "value" }`) as well. Parsing the real source keeps the two catalogs from
|
||||
/// drifting apart again instead of checking a remembered count.
|
||||
fn portuguese_keys() -> Vec<String> {
|
||||
let source = include_str!("pt.rs");
|
||||
let mut literals: Vec<(String, usize, usize)> = Vec::new();
|
||||
let mut i = 0;
|
||||
while i < source.len() {
|
||||
let rest = &source[i..];
|
||||
if rest.starts_with("//") {
|
||||
i += rest.find('\n').unwrap_or(rest.len());
|
||||
continue;
|
||||
}
|
||||
if rest.starts_with('"') {
|
||||
let (value, end) = string_literal(source, i).unwrap();
|
||||
literals.push((value, i, end));
|
||||
i = end;
|
||||
continue;
|
||||
}
|
||||
i += rest.chars().next().unwrap().len_utf8();
|
||||
}
|
||||
let mut keys = Vec::new();
|
||||
for pair in literals.windows(2) {
|
||||
let (key, _, end) = &pair[0];
|
||||
let start = pair[1].1;
|
||||
let gap = source[*end..start].trim();
|
||||
if gap == "=>" || gap == "=> {" {
|
||||
keys.push(key.clone());
|
||||
}
|
||||
}
|
||||
keys
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_are_unique_and_every_value_is_translated() {
|
||||
fn keys_are_unique_and_no_value_is_blank() {
|
||||
let mut seen = HashSet::new();
|
||||
for (english, japanese) in DIALOGS.iter().chain(CORE) {
|
||||
assert!(!japanese.is_empty(), "empty translation for {english:?}");
|
||||
assert!(japanese.chars().any(|c| !c.is_whitespace()), "blank translation for {english:?}");
|
||||
assert!(seen.insert(*english), "duplicate key across CORE and DIALOGS: {english:?}");
|
||||
}
|
||||
assert_eq!(seen.len(), catalog_keys().count());
|
||||
assert_eq!(CORE.len(), 392, "the shared reference set every catalog covers");
|
||||
assert_eq!(DIALOGS.len(), 562, "the dialog, message and menu strings beyond it");
|
||||
assert_eq!(catalog_keys().count(), 954);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extra_labels_are_translated_and_do_not_overlap_the_catalog() {
|
||||
fn extra_labels_do_not_overlap_the_catalog() {
|
||||
let catalog: HashSet<&str> = catalog_keys().collect();
|
||||
let mut seen = HashSet::new();
|
||||
for (english, japanese) in EXTRA {
|
||||
@@ -1252,40 +1170,4 @@ mod tests {
|
||||
assert_eq!(translate(english), Some(*japanese));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn covers_every_key_the_portuguese_catalog_translates() {
|
||||
let portuguese = portuguese_keys();
|
||||
assert_eq!(portuguese.len(), 954, "pt.rs's arms changed: add the new keys here and update the count");
|
||||
let japanese: HashSet<&str> = catalog_keys().collect();
|
||||
for key in &portuguese {
|
||||
assert!(japanese.contains(key.as_str()), "the Portuguese catalog translates {key:?}, the Japanese one does not");
|
||||
}
|
||||
assert_eq!(japanese.len(), portuguese.len(), "the two catalogs hold the same key set");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn high_value_strings_use_excel_japanese_wording() {
|
||||
for (english, japanese) in [
|
||||
("OK", "OK"),
|
||||
("Cancel", "キャンセル"),
|
||||
("Don't Save", "保存しない"),
|
||||
("Text Box", "テキスト ボックス"),
|
||||
("Row Height", "行の高さ"),
|
||||
("Column Width", "列の幅"),
|
||||
("Sort", "並べ替え"),
|
||||
("Filter", "フィルター"),
|
||||
("Conditional Formatting", "条件付き書式"),
|
||||
("Do you want to save the changes you made to {title}?", "{title} への変更を保存しますか?"),
|
||||
("Save a copy as .{format}", ".{format} としてコピーを保存"),
|
||||
("Format Cells|Fill", "塗りつぶし"),
|
||||
("Insert\nSlicer", "スライサーの\n挿入"),
|
||||
] {
|
||||
assert_eq!(translate(english), Some(japanese), "{english:?}");
|
||||
assert_eq!(crate::i18n::Language::Ja.tr(english), japanese, "{english:?}");
|
||||
}
|
||||
// A `context|label` key shows only its label in English; an unknown label keeps the text.
|
||||
assert_eq!(crate::i18n::Language::En.tr("Format Cells|Fill"), "Fill");
|
||||
assert_eq!(crate::i18n::Language::Ja.tr("Not in any catalog"), "Not in any catalog");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ use std::sync::{Arc, Mutex};
|
||||
|
||||
use egui_kittest::kittest::Queryable as _;
|
||||
use gridcraft_engine::{Session, core::CellRef, core::Value};
|
||||
use gridcraft_ui_egui::i18n::Language;
|
||||
use gridcraft_ui_egui::{Services, SheetApp};
|
||||
|
||||
fn harness(services: Services) -> egui_kittest::Harness<'static, SheetApp> {
|
||||
@@ -134,30 +135,25 @@ fn file_options_opens_the_options_page_and_pages_are_reachable_by_command() {
|
||||
|
||||
#[test]
|
||||
fn translated_options_headings_show_no_context_prefix() {
|
||||
for (code, headings, prefixed, (view, view_prefixed)) in [
|
||||
("fr", ["Général", "Enregistrer"], ["Options|Général", "Options|Enregistrer"], ("Affichage", "Options de la feuille|Affichage")),
|
||||
("sr", ["Opšte", "Sačuvaj"], ["Opcije|Opšte", "Opcije|Sačuvaj"], ("Prikaz", "Opcije lista|Prikaz")),
|
||||
("nl", ["Algemeen", "Opslaan"], ["Opties|Algemeen", "Opties|Opslaan"], ("Weergeven", "Bladopties|Weergeven")),
|
||||
("ro", ["General", "Salvare"], ["Opțiuni|General", "Opțiuni|Salvare"], ("Vizualizare", "Opțiuni foaie|Vizualizare")),
|
||||
] {
|
||||
for language in [Language::Fr, Language::Sr, Language::Nl, Language::Ro] {
|
||||
let mut h = harness(Services::default());
|
||||
h.state_mut().run("app.language.set", serde_json::json!({"language": code})).unwrap();
|
||||
h.state_mut().run("app.language.set", serde_json::json!({"language": language.code()})).unwrap();
|
||||
h.state_mut().run("file.options", serde_json::json!({})).unwrap();
|
||||
h.run_steps(3);
|
||||
assert_eq!(page(&h), Some("options"));
|
||||
for heading in headings {
|
||||
assert!(h.query_all_by_label(heading).next().is_some(), "{code}: no {heading} heading");
|
||||
}
|
||||
for label in prefixed {
|
||||
assert!(h.query_by_label(label).is_none(), "{code}: {label} shows its context");
|
||||
for key in ["Options|General", "Options|Save"] {
|
||||
let heading = language.tr(key);
|
||||
assert!(!heading.contains('|'), "{language:?}: {key} shows its context: {heading}");
|
||||
assert!(h.query_all_by_label(heading).next().is_some(), "{language:?}: no {heading} heading");
|
||||
}
|
||||
// Page Layout › Sheet Options: the Gridlines and Headings "View" checkboxes had the same
|
||||
// prefix. The View tab may share the word, so count at least the two checkboxes.
|
||||
h.state_mut().run("ui.backstage", serde_json::json!({"page": "close"})).unwrap();
|
||||
h.state_mut().ui.ribbon_tab = "Page Layout".into();
|
||||
h.run_steps(3);
|
||||
assert!(h.query_by_label(view_prefixed).is_none(), "{code}: {view_prefixed} shows its context");
|
||||
assert!(h.query_all_by_label(view).count() >= 2, "{code}: the Sheet Options checkboxes don't read {view}");
|
||||
let view = language.tr("Sheet Options|View");
|
||||
assert!(!view.contains('|'), "{language:?}: Sheet Options|View shows its context: {view}");
|
||||
assert!(h.query_all_by_label(view).count() >= 2, "{language:?}: the Sheet Options checkboxes don't read {view}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -176,14 +176,12 @@ fn portuguese_interface_retains_its_canonical_formula_fallback() {
|
||||
let mut app = app();
|
||||
app.run("app.language.set", json!({"code":"pt-BR"})).unwrap();
|
||||
assert_eq!(app.ui.language, Language::PtBr);
|
||||
assert_eq!(app.ui.language.tr("Data"), "Dados");
|
||||
assert!(enter(&mut app, "=SUM(1.5,2.25)"));
|
||||
app.begin_edit(None, true);
|
||||
assert_eq!(app.editor.as_ref().unwrap().text, "=SUM(1.5,2.25)");
|
||||
assert!(!enter(&mut app, "=SUMA(1;2)"));
|
||||
let restored: UiState = serde_json::from_str(&serde_json::to_string(&app.ui).unwrap()).unwrap();
|
||||
assert_eq!(restored.language, Language::PtBr);
|
||||
assert_eq!(Language::Es.tr("Page Layout|Orientation"), "Orientación");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -193,7 +191,6 @@ fn czech_interface_retains_its_canonical_formula_fallback() {
|
||||
let mut app = app();
|
||||
app.run("app.language.set", json!({"code":"cs-CZ"})).unwrap();
|
||||
assert_eq!(app.ui.language, Language::Cs);
|
||||
assert_eq!(app.ui.language.tr("Formulas"), "Vzorce");
|
||||
assert!(enter(&mut app, "=SUM(1.5,2.25)"));
|
||||
app.begin_edit(None, true);
|
||||
assert_eq!(app.editor.as_ref().unwrap().text, "=SUM(1.5,2.25)");
|
||||
@@ -225,8 +222,6 @@ fn turkish_interface_retains_its_canonical_formula_fallback() {
|
||||
let mut app = app();
|
||||
app.run("app.language.set", json!({"code":"tr-TR"})).unwrap();
|
||||
assert_eq!(app.ui.language, Language::Tr);
|
||||
assert_eq!(app.ui.language.tr("Data"), "Veri");
|
||||
assert_eq!(app.ui.language.tr("Formulas"), "Formüller");
|
||||
assert!(enter(&mut app, "=SUM(1.5,2.25)"));
|
||||
app.begin_edit(None, true);
|
||||
assert_eq!(app.editor.as_ref().unwrap().text, "=SUM(1.5,2.25)");
|
||||
|
||||
@@ -4,6 +4,7 @@ use egui::{Event, Key, Modifiers, PointerButton};
|
||||
use egui_kittest::kittest::Queryable as _;
|
||||
use gridcraft_engine::Session;
|
||||
use gridcraft_engine::core::{CellRef, Value};
|
||||
use gridcraft_ui_egui::i18n::Language;
|
||||
use gridcraft_ui_egui::{SheetApp, grid::Geo};
|
||||
use serde_json::json;
|
||||
|
||||
@@ -400,8 +401,7 @@ fn renders_every_ribbon_tab_without_panicking() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn language_switch_translates_the_ribbon_and_persists() {
|
||||
use gridcraft_ui_egui::i18n::Language;
|
||||
fn language_switch_renders_the_ribbon_and_persists() {
|
||||
let mut h = harness(blank());
|
||||
// English by default whatever the host's locale: only the desktop app reads the system language.
|
||||
assert_eq!(h.state().ui.language, Language::En);
|
||||
@@ -415,31 +415,13 @@ fn language_switch_translates_the_ribbon_and_persists() {
|
||||
for id in ["app.language.set", "app.language.english", "app.language.japanese"] {
|
||||
assert!(ids.contains(&id), "engine.commands lists {id}");
|
||||
}
|
||||
for (language, expected) in [
|
||||
(Language::Zh, "数据"),
|
||||
(Language::Ja, "データ"),
|
||||
(Language::Ko, "데이터"),
|
||||
(Language::Ru, "Данные"),
|
||||
(Language::PtBr, "Dados"),
|
||||
(Language::De, "Daten"),
|
||||
(Language::Fr, "Données"),
|
||||
(Language::Tr, "Veri"),
|
||||
(Language::It, "Dati"),
|
||||
] {
|
||||
for language in Language::ALL {
|
||||
h.state_mut().run("app.language.set", json!({"language": language.code()})).unwrap();
|
||||
h.run_steps(2); // every locale renders the ribbon without panicking
|
||||
assert_eq!(h.state().ui.language, language);
|
||||
assert_eq!(language.tr("Data"), expected);
|
||||
let restored: gridcraft_ui_egui::UiState = serde_json::from_str(&serde_json::to_string(&h.state().ui).unwrap()).unwrap();
|
||||
assert_eq!(restored.language, language);
|
||||
}
|
||||
// Czech spells "Data" as English does, so the Czech table is checked on a label that differs.
|
||||
h.state_mut().run("app.language.set", json!({"language": "cs"})).unwrap();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().ui.language, Language::Cs);
|
||||
assert_eq!(Language::Cs.tr("Formulas"), "Vzorce");
|
||||
let restored: gridcraft_ui_egui::UiState = serde_json::from_str(&serde_json::to_string(&h.state().ui).unwrap()).unwrap();
|
||||
assert_eq!(restored.language, Language::Cs);
|
||||
h.state_mut().run("app.language.set", json!({"language": "en"})).unwrap();
|
||||
assert_eq!(h.state().ui.language, Language::En);
|
||||
}
|
||||
@@ -452,16 +434,21 @@ fn pivot_fields_pane_follows_the_interface_language() {
|
||||
h.state_mut().run("insert.pivotTable", json!({"source": "Sheet1!$A$1:$B$3", "rows": ["Region"]})).unwrap();
|
||||
h.state_mut().run("app.language.set", json!({"language": "es"})).unwrap();
|
||||
h.run_steps(4);
|
||||
for label in ["Campos de tabla dinámica", "Filas", "Valores", "Actualizar", "Campo calculado…"] {
|
||||
assert!(h.query_by_label(label).is_some(), "the pane shows `{label}`");
|
||||
let es = |english| Language::Es.tr(english);
|
||||
for english in ["PivotTable Fields", "Rows", "Values", "Refresh", "Calculated Field…"] {
|
||||
assert!(h.query_by_label(es(english)).is_some(), "the pane shows `{}`", es(english));
|
||||
}
|
||||
if es("PivotTable Fields") != "PivotTable Fields" {
|
||||
assert!(h.query_by_label("PivotTable Fields").is_none());
|
||||
}
|
||||
assert!(h.query_by_label("PivotTable Fields").is_none());
|
||||
h.get_by_label("Region ▾").click();
|
||||
h.run_steps(2);
|
||||
for label in ["Mover a rótulos de columna", "Agrupar por años", "Quitar campo"] {
|
||||
assert!(h.query_by_label(label).is_some(), "the field menu shows `{label}`");
|
||||
for english in ["Move to Column Labels", "Group by Years", "Remove Field"] {
|
||||
assert!(h.query_by_label(es(english)).is_some(), "the field menu shows `{}`", es(english));
|
||||
}
|
||||
if es("Remove Field") != "Remove Field" {
|
||||
assert!(h.query_by_label("Remove Field").is_none());
|
||||
}
|
||||
assert!(h.query_by_label("Remove Field").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -492,9 +479,12 @@ fn format_cells_draws_its_labels_in_the_interface_language() {
|
||||
h.state_mut().run("app.language.set", json!({"language": "de"})).unwrap();
|
||||
h.state_mut().open_dialog("formatCells", json!({"tab": "Number"}));
|
||||
h.run_steps(2);
|
||||
assert!(h.query_by_label("Wissenschaftlich").is_some(), "the Number tab's categories are translated");
|
||||
assert!(h.query_by_label("Scientific").is_none(), "no English category is left");
|
||||
h.get_by_label("Wissenschaftlich").click();
|
||||
let scientific = Language::De.tr("Scientific");
|
||||
assert!(h.query_by_label(scientific).is_some(), "the Number tab's categories are translated");
|
||||
if scientific != "Scientific" {
|
||||
assert!(h.query_by_label("Scientific").is_none(), "no English category is left");
|
||||
}
|
||||
h.get_by_label(scientific).click();
|
||||
h.run_steps(2);
|
||||
let style = &h.state().dialog.as_ref().unwrap().values["style"];
|
||||
assert_eq!(style["num_fmt"], json!("0.00E+00"), "a translated category still applies its code");
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
use egui_kittest::kittest::Queryable as _;
|
||||
use gridcraft_engine::Session;
|
||||
use gridcraft_ui_egui::SheetApp;
|
||||
use gridcraft_ui_egui::i18n::Language;
|
||||
use serde_json::json;
|
||||
|
||||
fn harness() -> egui_kittest::Harness<'static, SheetApp> {
|
||||
@@ -23,19 +24,17 @@ fn harness() -> egui_kittest::Harness<'static, SheetApp> {
|
||||
|
||||
#[test]
|
||||
fn form_fields_are_translated() {
|
||||
for (dialog, translated, english) in [
|
||||
("pageSetup", ["Margem esquerda (pol.):", "Rodapé (pol.):"], ["Left margin (in):", "Footer (in):"]),
|
||||
("insertPaste", ["Deslocar células para a direita", "Deslocar células para baixo"], ["Shift cells right", "Shift cells down"]),
|
||||
] {
|
||||
for (dialog, labels) in [("pageSetup", ["Left margin (in):", "Footer (in):"]), ("insertPaste", ["Shift cells right", "Shift cells down"])] {
|
||||
let mut h = harness();
|
||||
h.state_mut().run("app.language.set", json!({"language": "pt"})).unwrap();
|
||||
h.state_mut().open_dialog(dialog, json!({}));
|
||||
h.run_steps(3);
|
||||
for label in translated {
|
||||
assert!(h.query_by_label_contains(label).is_some(), "{dialog}: no {label}");
|
||||
}
|
||||
for label in english {
|
||||
assert!(h.query_by_label_contains(label).is_none(), "{dialog}: {label} is untranslated");
|
||||
for english in labels {
|
||||
let translated = Language::PtBr.tr(english);
|
||||
assert!(h.query_by_label_contains(translated).is_some(), "{dialog}: no {translated}");
|
||||
if translated != english {
|
||||
assert!(h.query_by_label_contains(english).is_none(), "{dialog}: {english} is untranslated");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Localization parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (the Japanese catalog covers the dialogs, messages and menus; Dutch interface language; Romanian interface catalog) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (missing translations are listed by `cargo xtask i18n-report` and never fail a check) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
|
||||
|
||||
Per-language status against Excel, which ships its UI in dozens of languages (Windows language
|
||||
packs cover 100+), with localized function names, argument separators, number and date formats,
|
||||
@@ -96,8 +96,9 @@ language (none so far) and of localized function names.
|
||||
## Infrastructure (do first)
|
||||
|
||||
1. Extend the catalog from the ribbon to every user-visible string (dialogs, menus, task panes,
|
||||
status and error messages, command labels, function descriptions), with a missing-key check
|
||||
in `cargo xtask ci`. Language switching and the system-language default already exist (#31).
|
||||
status and error messages, command labels, function descriptions). A string a catalog lacks
|
||||
shows in English and never fails a check: `cargo xtask i18n-report` lists the missing strings
|
||||
per language, and `cargo xtask ci` runs it first. Language switching and the system-language default already exist (#31).
|
||||
2. Locale-aware formulas: localized function-name tables and separators at the formula-bar
|
||||
boundary only (files keep en-US invariant names), locale number and date formats, list
|
||||
separators in CSV.
|
||||
@@ -109,6 +110,7 @@ language (none so far) and of localized function names.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-23 | minor | Missing translations are reported, never a failure: `cargo xtask i18n-report` prints, per language, the strings other catalogs translate and it lacks (plus Spanish function help rows keyed by English the function catalog no longer has) and always passes; `cargo xtask ci` runs it first. Coverage and per-language parity tests and tests of one language's exact wording are gone; UI tests take the expected text from the catalog. Duplicate-key, placeholder, context-prefix and line-shape checks stay |
|
||||
| 2026-10-21 | minor | Romanian interface (`ro`, Română): 970 labels covering every key of the other catalogs (`crates/ui-egui/src/i18n/ro.rs`), `ro-*` system locales, the View-tab switcher and `app.language.set`; context keys show no prefix (File › Options test). English formulas and formats; AI-assisted draft pending native-speaker review |
|
||||
| 2026-10-20 | minor | Japanese: furigana (`rPh` phonetic runs and `<phoneticPr>` in `xl/sharedStrings.xml`) is no longer deleted by an open + save; it is kept on the workbook and written back (a `<phoneticPr>` without runs is kept too, while duplicate texts with different readings keep only the first, reported on open). Display of the readings is still missing (`crates/xlsx/src/tests/phonetic_tests.rs`) |
|
||||
| 2026-10-20 | minor | Japanese catalog (`i18n/ja.rs`) covers every key the Brazilian Portuguese catalog translates: the 398-label shared core moved verbatim plus 545 dialog, message, Backstage and menu strings — 943 keys, ~12% → ~43%. A test parses `pt.rs`'s source, so the two catalogs can't drift apart again. Portuguese re-measured at 943 (was 923) |
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
description = "GridCraft workspace tooling: cargo xtask ci | layers | assets | wasm | parity | version | stats | ico"
|
||||
description = "GridCraft workspace tooling: cargo xtask ci | layers | assets | wasm | parity | i18n-report | version | stats | ico"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
//! `cargo xtask i18n-report`: the interface strings each language doesn't translate yet, read from
|
||||
//! the catalogs in `crates/ui-egui/src/i18n/`. A string a catalog lacks shows in English, so the
|
||||
//! report always succeeds; `cargo xtask ci` runs it first so the list shows in the gate log.
|
||||
//!
|
||||
//! The reference set is every key any catalog translates. A catalog is a source file with a
|
||||
//! `fn translate`; its keys are the string literals that start a match arm (`"key" => …`) or open a
|
||||
//! table row (`("key", …)`). With the `engine` feature it also lists function help rows keyed by
|
||||
//! English the function catalog no longer has: those show the English help.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::fmt::Write as _;
|
||||
use std::path::Path;
|
||||
|
||||
/// Missing keys listed per language; the rest are counted.
|
||||
const LIST_CAP: usize = 50;
|
||||
|
||||
pub fn run(root: &Path) -> Result<(), String> {
|
||||
let dir = root.join("crates/ui-egui/src/i18n");
|
||||
let mut catalogs = BTreeMap::new();
|
||||
let mut help = BTreeMap::new();
|
||||
let entries = match std::fs::read_dir(&dir) {
|
||||
Ok(entries) => entries,
|
||||
Err(e) => {
|
||||
println!("i18n-report: can't read {}: {e}", dir.display());
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
let Some(stem) = path.file_stem().and_then(|s| s.to_str()).map(str::to_owned) else { continue };
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("rs") {
|
||||
continue;
|
||||
}
|
||||
let Ok(source) = std::fs::read_to_string(&path) else { continue };
|
||||
if let Some(language) = stem.strip_suffix("_functions") {
|
||||
help.insert(language.to_owned(), catalog_keys(&source));
|
||||
} else if source.contains("fn translate(") {
|
||||
catalogs.insert(stem, catalog_keys(&source));
|
||||
}
|
||||
}
|
||||
print!("{}", render(&catalogs));
|
||||
print!("{}", function_help(&help));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Per language: how many reference keys it lacks, then the first [`LIST_CAP`] of them.
|
||||
pub fn render(catalogs: &BTreeMap<String, BTreeSet<String>>) -> String {
|
||||
let reference: BTreeSet<&String> = catalogs.values().flatten().collect();
|
||||
let mut out =
|
||||
format!("i18n-report: {} interface strings across {} catalogs; a string a catalog lacks shows in English\n", reference.len(), catalogs.len());
|
||||
for (language, keys) in catalogs {
|
||||
let missing: Vec<&&String> = reference.iter().filter(|key| !keys.contains(key.as_str())).collect();
|
||||
let _ = writeln!(out, " {language}: {} missing", missing.len());
|
||||
list(&mut out, missing.iter().map(|key| key.as_str()), missing.len());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn list<'a>(out: &mut String, keys: impl Iterator<Item = &'a str>, count: usize) {
|
||||
for key in keys.take(LIST_CAP) {
|
||||
let _ = writeln!(out, " {key:?}");
|
||||
}
|
||||
if count > LIST_CAP {
|
||||
let _ = writeln!(out, " … and {} more", count - LIST_CAP);
|
||||
}
|
||||
}
|
||||
|
||||
/// Function help rows whose English the function catalog no longer has (an English description
|
||||
/// reworded, an argument renamed).
|
||||
#[cfg(feature = "engine")]
|
||||
fn function_help(help: &BTreeMap<String, BTreeSet<String>>) -> String {
|
||||
let catalog = gridcraft_engine::cmd::formulas::function_list();
|
||||
let mut known: BTreeSet<&str> = catalog.iter().filter_map(|f| f["description"].as_str()).collect();
|
||||
known.extend(catalog.iter().filter_map(|f| f["signature"].as_str()).flat_map(argument_names));
|
||||
let mut out = String::new();
|
||||
for (language, keys) in help {
|
||||
let stale: Vec<&str> = keys.iter().map(String::as_str).filter(|key| !known.contains(key)).collect();
|
||||
let _ = writeln!(out, " {language} function help: {} rows, {} keyed by English the function catalog no longer has", keys.len(), stale.len());
|
||||
list(&mut out, stale.iter().copied(), stale.len());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "engine"))]
|
||||
fn function_help(_: &BTreeMap<String, BTreeSet<String>>) -> String {
|
||||
String::new()
|
||||
}
|
||||
|
||||
/// `SUM(number1, [number2], ...)` -> `number`, `number`: brackets, index and `...` dropped.
|
||||
#[cfg(feature = "engine")]
|
||||
fn argument_names(signature: &str) -> Vec<&str> {
|
||||
let Some(inner) = signature.split_once('(').and_then(|(_, rest)| rest.strip_suffix(')')) else { return Vec::new() };
|
||||
inner
|
||||
.split(", ")
|
||||
.map(|arg| {
|
||||
let arg = arg.strip_prefix('[').unwrap_or(arg);
|
||||
let arg = arg.strip_suffix(']').unwrap_or(arg);
|
||||
arg.trim_end_matches(|c: char| c.is_ascii_digit())
|
||||
})
|
||||
.filter(|name| !name.is_empty() && *name != "...")
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The keys of one catalog source: string literals that start a match arm or open a table row.
|
||||
/// Comments and the test module are skipped.
|
||||
pub fn catalog_keys(source: &str) -> BTreeSet<String> {
|
||||
let source = source.split("#[cfg(test)]").next().unwrap_or(source);
|
||||
let mut keys = BTreeSet::new();
|
||||
for (value, start, end) in literals(source) {
|
||||
let before = source.get(..start).unwrap_or("").trim_end();
|
||||
let after = source.get(end..).unwrap_or("").trim_start();
|
||||
if after.starts_with("=>") || (before.ends_with('(') && after.starts_with(',')) {
|
||||
keys.insert(value);
|
||||
}
|
||||
}
|
||||
keys
|
||||
}
|
||||
|
||||
/// Every string literal outside `//` comments: its decoded value, and the byte range from its
|
||||
/// opening quote to just past its closing one.
|
||||
fn literals(source: &str) -> Vec<(String, usize, usize)> {
|
||||
let mut out = Vec::new();
|
||||
let mut chars = source.char_indices().peekable();
|
||||
while let Some((i, c)) = chars.next() {
|
||||
if c == '/' && source.get(i..).is_some_and(|rest| rest.starts_with("//")) {
|
||||
for (_, c) in chars.by_ref() {
|
||||
if c == '\n' {
|
||||
break;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if c != '"' {
|
||||
continue;
|
||||
}
|
||||
let mut value = String::new();
|
||||
let mut end = source.len();
|
||||
while let Some((j, c)) = chars.next() {
|
||||
match c {
|
||||
'"' => {
|
||||
end = j + 1;
|
||||
break;
|
||||
}
|
||||
'\\' => match chars.next().map(|(_, c)| c) {
|
||||
Some('n') => value.push('\n'),
|
||||
Some('t') => value.push('\t'),
|
||||
Some('r') => value.push('\r'),
|
||||
Some('0') => value.push('\0'),
|
||||
Some(c @ ('\\' | '"' | '\'')) => value.push(c),
|
||||
Some('u') => {
|
||||
let mut hex = String::new();
|
||||
for (_, c) in chars.by_ref() {
|
||||
match c {
|
||||
'}' => break,
|
||||
'{' => {}
|
||||
c => hex.push(c),
|
||||
}
|
||||
}
|
||||
if let Some(c) = u32::from_str_radix(&hex, 16).ok().and_then(char::from_u32) {
|
||||
value.push(c);
|
||||
}
|
||||
}
|
||||
// A line continuation skips the newline and the next line's indentation.
|
||||
Some('\n') => {
|
||||
while chars.peek().is_some_and(|(_, c)| c.is_whitespace()) {
|
||||
chars.next();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
c => value.push(c),
|
||||
}
|
||||
}
|
||||
out.push((value, i, end));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn reads_match_arms_and_table_rows_and_lists_what_each_catalog_lacks() {
|
||||
let matched = r#"
|
||||
pub(super) fn translate(text: &str) -> Option<&'static str> {
|
||||
Some(match text {
|
||||
"Paste" => "Einfügen", // "Commented" => "x",
|
||||
"Line\nbreak \"quoted\"" => {
|
||||
"Zeile"
|
||||
}
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests { const T: &[(&str, &str)] = &[("Test only", "x")]; }
|
||||
"#;
|
||||
let table = r#"
|
||||
pub(super) const CORE: &[(&str, &str)] = &[
|
||||
("Paste", "貼り付け"),
|
||||
(
|
||||
"Long \u{2026}",
|
||||
"長い",
|
||||
),
|
||||
];
|
||||
pub(super) fn translate(text: &str) -> Option<&'static str> { None }
|
||||
"#;
|
||||
let de = catalog_keys(matched);
|
||||
assert_eq!(de.iter().map(String::as_str).collect::<Vec<_>>(), ["Line\nbreak \"quoted\"", "Paste"]);
|
||||
let ja = catalog_keys(table);
|
||||
assert_eq!(ja.iter().map(String::as_str).collect::<Vec<_>>(), ["Long …", "Paste"]);
|
||||
|
||||
let report = render(&BTreeMap::from([("de".to_owned(), de), ("ja".to_owned(), ja)]));
|
||||
assert!(report.starts_with("i18n-report: 3 interface strings across 2 catalogs"), "{report}");
|
||||
assert!(report.contains(" de: 1 missing\n \"Long …\"\n"), "{report}");
|
||||
assert!(report.contains(" ja: 1 missing\n \"Line\\nbreak \\\"quoted\\\"\"\n"), "{report}");
|
||||
}
|
||||
}
|
||||
+8
-3
@@ -6,6 +6,7 @@
|
||||
#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used, clippy::panic))]
|
||||
|
||||
mod assets;
|
||||
mod i18n_report;
|
||||
mod ico;
|
||||
mod keytips;
|
||||
mod layers;
|
||||
@@ -21,11 +22,12 @@ const USAGE: &str = "\
|
||||
usage: cargo xtask <command>
|
||||
|
||||
commands:
|
||||
ci fmt --check, clippy -D warnings, test, doctest, assets, layers, wasm, parity --check,
|
||||
keytips --check (stops at the first failure; the parity check runs only if
|
||||
docs/parity-checklist.md exists)
|
||||
ci i18n-report, fmt --check, clippy -D warnings, test, doctest, assets, layers, wasm,
|
||||
parity --check, keytips --check (stops at the first failure; the parity check runs
|
||||
only if docs/parity-checklist.md exists)
|
||||
test workspace tests (cargo nextest when installed, else cargo test), then doctests
|
||||
assets check that every icon/image/font/sample file is attributed in ATTRIBUTION.md
|
||||
i18n-report list the interface strings each language doesn't translate yet (always passes)
|
||||
layers enforce the crate dependency layering (AGENTS.md \"Layering\")
|
||||
wasm cargo check --target wasm32-unknown-unknown for the L0-L5 crates (+ gridcraft-web), one call
|
||||
parity [--check] [--out PATH]
|
||||
@@ -44,6 +46,7 @@ fn main() -> ExitCode {
|
||||
let rest: Vec<&str> = args.iter().skip(1).map(String::as_str).collect();
|
||||
let result = match args.first().map(String::as_str) {
|
||||
Some("assets") => assets::run(&root()),
|
||||
Some("i18n-report") => i18n_report::run(&root()),
|
||||
Some("layers") => cmd_layers(),
|
||||
Some("wasm") => cmd_wasm(),
|
||||
Some("test") => cmd_test().and_then(|()| cmd_doctest()),
|
||||
@@ -194,6 +197,8 @@ fn cmd_doctest() -> Result<(), String> {
|
||||
fn cmd_ci() -> Result<(), String> {
|
||||
type Step = (&'static str, Box<dyn Fn() -> Result<(), String>>);
|
||||
let steps: Vec<Step> = vec![
|
||||
// Missing translations never fail the gate; the list goes first so every log shows it.
|
||||
("i18n-report", Box::new(|| i18n_report::run(&root()))),
|
||||
(
|
||||
"fmt",
|
||||
Box::new(|| {
|
||||
|
||||
Reference in New Issue
Block a user