feat(i18n): look interface strings up through the craft-i18n crate

The i18n module becomes a thin wrapper over craft_i18n::I18n built from
the embedded catalogs, offering exactly LANGUAGES (slimmed to code,
prefer_hans, editing_lang; names come from @id language.name).
WORDCRAFT_LANGUAGE and the sys-locale read stay; non-Brazilian pt-* stays
English before the crate's matching (test). The pseudo-language uses
the crate's Lang::PSEUDO and looked_up(). The Design tab's "Open"
spacing preset uses get("spacing", "Open") so it never shows the file
verb. The missing-translation report adds check_all() problems and a
kept-English bucket and never fails; the untranslated-text audit still
fails on text that skips tl!. Catalog-dependent tests skip with a note
when nothing was embedded; a test keeps the flatpak commit equal to
craft-i18n.rev.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Martin Ball
2026-10-23 22:20:55 +01:00
co-authored by Claude Opus 5.5
parent e649cd09da
commit 2d90f2d071
6 changed files with 374 additions and 340 deletions
+20 -6
View File
@@ -343,14 +343,25 @@ fn untranslated_interface_text() {
assert!(found.is_empty(), "{} interface texts skip translation (wrap them in tl!):\n{report}", found.len());
}
/// The missing-translation report (`cargo xtask i18n-report`, part of `cargo xtask ci`): prints, per
/// language, the interface strings its catalog has no entry for. A missing translation falls back
/// to English, so the report never fails. The strings are the ones the audit saw looked up, the
/// The missing-translation report (craft-i18n's S3; `cargo xtask i18n-report`, part of `cargo xtask
/// ci`): the embedded catalogs' problems (the crate's `check_all`), and per language the interface
/// strings with no translation (the missing bucket, which translators fill in craft-i18n) and those
/// whose translation is the English itself (kept English, not counted as translated). A missing
/// translation falls back to English, so the report never fails; without embedded catalogs
/// (`CRAFT_I18N_DIR` unset) it is skipped. The strings are the ones the audit saw looked up, the
/// ribbon tabs, every command's label and ribbon location, the File › Home greetings, every reason
/// a command gives for being off (#2678) and the equation gallery's labels (shown in menus the
/// audit doesn't open one by one). Numbered style names go through a template.
fn report_catalog_gaps(mut keys: std::collections::BTreeSet<String>) {
use wordcraft_engine::math_gallery::{BUILT_INS, STRUCTURES, SYMBOL_SETS};
if super::CRAFT_I18N.is_empty() {
eprintln!("missing-translation report skipped: built without craft-i18n (set CRAFT_I18N_DIR to a craft-i18n checkout)");
return;
}
let problems = super::I18N.check_all();
if !problems.is_empty() {
eprintln!("{} catalog rows are skipped (they show in English):\n{problems:#?}", problems.len());
}
let tabs = crate::ribbon::TABS.iter().chain(&["Table Design", "Table Layout"]);
keys.extend(tabs.map(|t| t.to_string()));
keys.extend(["Good morning", "Good afternoon", "Good evening"].map(String::from));
@@ -407,10 +418,13 @@ fn report_catalog_gaps(mut keys: std::collections::BTreeSet<String>) {
// translate: its placeholder names aren't words the interface shows.
let words = |s: &str| english_words(&s.split('{').map(|p| p.split_once('}').map_or(p, |(_, rest)| rest)).collect::<String>());
for lang in super::Lang::all().filter(|l| *l != super::Lang::EN) {
let missing: Vec<&String> =
keys.iter().filter(|s| words(s) && !super::has(lang, s) && !numbered(s) && !same(s) && !script_error(s)).collect();
let reported = keys.iter().filter(|s| words(s) && !numbered(s) && !same(s) && !script_error(s));
let (kept, missing): (Vec<&String>, Vec<&String>) = reported.filter(|s| super::tr(lang, s) == s.as_str()).partition(|s| super::has(lang, s));
if !missing.is_empty() {
eprintln!("{} strings have no {} entry:\n{missing:#?}", missing.len(), lang.code());
eprintln!("{} strings have no {} translation:\n{missing:#?}", missing.len(), lang.code());
}
if !kept.is_empty() {
eprintln!("{} strings are kept in English in {}:\n{kept:#?}", kept.len(), lang.code());
}
}
}
+159 -211
View File
@@ -1,256 +1,216 @@
//! Interface languages (#8). Strings in the code stay English and are the lookup keys; a catalog
//! per language (`*.tsv`, format in `zh-hans.tsv`) maps them to display text when the UI is drawn.
//! Command ids, document text, file names, the control channel, the CLI and MCP never see
//! translated text, so scripts and agents are unaffected. A string without a translation shows in
//! English, so coverage can grow one entry at a time.
//! Interface languages (#8). Strings in the code stay English and are the lookup keys; the
//! craft-i18n catalogs map them to display text when the UI is drawn. Command ids, document text,
//! file names, the control channel, the CLI and MCP never see translated text, so scripts and
//! agents are unaffected. A string without a translation shows in English, so coverage can grow
//! one entry at a time, and a missing translation never fails a build, a test or a release.
//!
//! The catalogs live in [storytold/craft-i18n](https://github.com/storytold/craft-i18n)
//! (`locales/<locale>/common.tsv` and `wordcraft.tsv`), not in this repository. `build.rs` embeds
//! them from `CRAFT_I18N_DIR` (CI and releases check out the SHA in `craft-i18n.rev`); without it
//! the build is English only. Lookup, fallback (`pt-br` → `pt` → English, app rows over shared
//! ones), plurals, language names and locale matching are the `craft-i18n` crate's (craft-libs).
//!
//! The `language` setting is `auto` by default: the UI follows the operating system's language
//! (Windows display language, macOS preferred languages, `LANG`/`LC_*` elsewhere) when there is a
//! catalog for it, and English otherwise. File ▸ Options ▸ Interface language picks one by hand.
//!
//! This is the system PdfCraft and PhotoCraft use (`pdfcraft/crates/ui-egui/src/i18n`), so the
//! catalog format and habits carry over between the Crafting Apps.
//! # Adding or changing a translation
//! In craft-i18n: add rows to `locales/<locale>/wordcraft.tsv` (its `docs/integration.md`); they
//! show here once `craft-i18n.rev` moves to a commit that has them. A feature PR here adds the
//! English only.
//!
//! # Adding a language
//! 1. Add `xx.tsv` next to `zh-hans.tsv` (copy its header; translate from the *meaning* of the
//! English text, clean-room: never from Microsoft Word's or another product's localisation).
//! 1. craft-i18n gets `locales/<code>/wordcraft.tsv` and the language's `@id language.name` row.
//! 2. Add one row to [`LANGUAGES`]. Set its `editing_lang` only when `wordcraft-proof` can proof
//! the language (Polish: `pl-PL`): new documents are then written in it.
//!
//! The Options dropdown, the system-language match and the catalog tests pick it up from there.
//! The Options dropdown and the system-language match pick it up from there.
//!
//! # Looking strings up
//! - [`tl!`](crate::tl) / [`t`]: a string in the current language. [`tr`]: in a given one.
//! - [`tc`] / [`trc`]: worded for a context (`preview`), for text that must fit a tight spot.
//! - [`get`]: one context's translation only, never the plain row (`spacing`'s "Open").
//! - [`location`]: a ribbon location such as `Home › Font`, segment by segment.
//! - [`fmt`]: fill `{name}` placeholders after a lookup; translators may reorder them.
mod catalog;
use std::cell::Cell;
use std::sync::OnceLock;
use std::sync::{LazyLock, OnceLock};
use catalog::Catalog;
use craft_i18n::I18n;
pub use craft_i18n::fmt;
include!(concat!(env!("OUT_DIR"), "/craft_i18n.rs"));
/// The embedded catalogs, offering exactly the languages in [`LANGUAGES`]. Built on first use;
/// a locale is parsed on its first lookup.
static I18N: LazyLock<I18n> = LazyLock::new(|| {
let offered: Vec<&str> = LANGUAGES.iter().map(|l| l.code).collect();
let (i18n, problems) = I18n::new(CRAFT_I18N, &offered);
problems.iter().for_each(|p| log::warn!("interface catalogs: {p}"));
i18n
});
/// The `language` setting that follows the system.
pub const AUTO: &str = "auto";
/// One supported interface language.
/// One interface language WordCraft offers: what the app needs beyond the catalogs. Its name and
/// translations come from craft-i18n.
pub struct LangInfo {
/// BCP 47 code, lowercase (`ja`, `zh-hans`). Also the `language` setting's value.
/// BCP 47 code, lowercase (`ja`, `zh-hans`). Also the `language` setting's value and the
/// craft-i18n locale.
pub code: &'static str,
/// The language's name in itself, shown in the Options dropdown.
pub name: &'static str,
/// Catalog file contents (empty for the built-in English).
pub source: &'static str,
/// Which CJK interface face comes first (Chinese text wants the Chinese face).
pub prefer_hans: bool,
/// The language (BCP 47) new blank documents are written in with this interface, for
/// languages WordCraft can proof; `None` keeps WordCraft's default, US English.
pub editing_lang: Option<&'static str>,
catalog: OnceLock<Catalog>,
}
/// The registry. English first: it is the fallback and the source language.
/// The languages WordCraft offers, when their catalogs are embedded. English first: it is the
/// fallback and the source language.
pub static LANGUAGES: [LangInfo; 27] = [
LangInfo { code: "en", name: "English", source: "", prefer_hans: false, editing_lang: None, catalog: OnceLock::new() },
// Simplified Chinese; `zh`, `zh-CN`, `zh-SG` and `zh-Hans-*` resolve here (see `candidates`).
LangInfo {
code: "zh-hans",
name: "简体中文",
source: include_str!("zh-hans.tsv"),
prefer_hans: true,
editing_lang: None,
catalog: OnceLock::new(),
},
LangInfo { code: "en", prefer_hans: false, editing_lang: None },
// Simplified Chinese; `zh`, `zh-CN`, `zh-SG` and `zh-Hans-*` resolve here (the crate's matching).
LangInfo { code: "zh-hans", prefer_hans: true, editing_lang: None },
// Traditional Chinese (Taiwan vocabulary); `zh-TW`, `zh-HK`, `zh-MO` and `zh-Hant-*` resolve here.
LangInfo {
code: "zh-hant",
name: "繁體中文",
source: include_str!("zh-hant.tsv"),
prefer_hans: true,
editing_lang: None,
catalog: OnceLock::new(),
},
LangInfo { code: "ja", name: "日本語", source: include_str!("ja.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() },
LangInfo {
code: "ko",
name: "한국어",
source: include_str!("ko.tsv"),
prefer_hans: false, // 일본어처럼 CJK지만 한자 우선순위는 필요 없으므로 false
editing_lang: None, // 현재 한국어 맞춤법(proofing) 지원이 없으므로 None
catalog: OnceLock::new(),
},
LangInfo {
code: "uk", name: "Українська", source: include_str!("uk.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new()
},
LangInfo { code: "zh-hant", prefer_hans: true, editing_lang: None },
LangInfo { code: "ja", prefer_hans: false, editing_lang: None },
LangInfo { code: "ko", prefer_hans: false, editing_lang: None },
LangInfo { code: "uk", prefer_hans: false, editing_lang: None },
// Spanish, neutral across Spain and Latin America; `es-ES`, `es-MX`, `es-419` … resolve here.
LangInfo { code: "es", name: "Español", source: include_str!("es.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() },
LangInfo { code: "es", prefer_hans: false, editing_lang: None },
// Brazilian Portuguese; `pt-BR` and `pt-BR-*` resolve here. Plain `pt` and `pt-PT` have no
// catalog yet (the European vocabulary differs), so they stay in English.
LangInfo {
code: "pt-br",
name: "Português (Brasil)",
source: include_str!("pt-br.tsv"),
prefer_hans: false,
editing_lang: Some("pt-BR"),
catalog: OnceLock::new(),
},
// catalog yet (the European vocabulary differs), so they stay in English ([`lang_from_tag`]).
LangInfo { code: "pt-br", prefer_hans: false, editing_lang: Some("pt-BR") },
// Serbian, Cyrillic script (the default per BCP 47 when no script is given); `sr`, `sr-RS`,
// `sr-Cyrl-*` resolve here.
LangInfo { code: "sr", name: "Српски", source: include_str!("sr.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() },
// Serbian, Latin script; `sr-Latn-*` resolves here (see `candidates`'s generic prefix
// matching — no special-casing needed, unlike Chinese's script-by-region fallback).
LangInfo {
code: "sr-latn",
name: "Srpski (latinica)",
source: include_str!("sr-latn.tsv"),
prefer_hans: false,
editing_lang: None,
catalog: OnceLock::new(),
},
LangInfo { code: "sr", prefer_hans: false, editing_lang: None },
// Serbian, Latin script; `sr-Latn-*` resolves here; a script is never
// crossed, so Latin Serbian never shows Cyrillic.
LangInfo { code: "sr-latn", prefer_hans: false, editing_lang: None },
// Polish; `pl`, `pl-PL` and `pl_PL.UTF-8` resolve here. WordCraft proofs Polish (spelling,
// grammar, hyphenation), so new documents are written in Polish with this interface.
LangInfo {
code: "pl",
name: "Polski",
source: include_str!("pl.tsv"),
prefer_hans: false,
editing_lang: Some("pl-PL"),
catalog: OnceLock::new(),
},
LangInfo {
code: "cs",
name: "Čeština",
source: include_str!("cs.tsv"),
prefer_hans: false,
editing_lang: Some("cs-CZ"),
catalog: OnceLock::new(),
},
LangInfo { code: "et", name: "Eesti", source: include_str!("et.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() },
LangInfo { code: "pl", prefer_hans: false, editing_lang: Some("pl-PL") },
LangInfo { code: "cs", prefer_hans: false, editing_lang: Some("cs-CZ") },
LangInfo { code: "et", prefer_hans: false, editing_lang: None },
// German, neutral across Germany, Austria and Switzerland; `de`, `de-DE`, `de-AT`, `de-CH` …
// resolve here.
LangInfo { code: "de", name: "Deutsch", source: include_str!("de.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() },
LangInfo { code: "de", prefer_hans: false, editing_lang: None },
// Russian; `ru`, `ru-RU`, `ru_RU.UTF-8` and other regions resolve here.
LangInfo { code: "ru", name: "Русский", source: include_str!("ru.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() },
LangInfo {
code: "tr",
name: "Türkçe",
source: include_str!("tr.tsv"),
prefer_hans: false,
editing_lang: Some("tr-TR"),
catalog: OnceLock::new(),
},
LangInfo { code: "ru", prefer_hans: false, editing_lang: None },
LangInfo { code: "tr", prefer_hans: false, editing_lang: Some("tr-TR") },
// Italian; `it-IT`, `it-CH` and `it-*` resolve here. New documents are written in
// Italian: `wordcraft-proof` checks it (`ProofLang::It`).
LangInfo {
code: "it",
name: "Italiano",
source: include_str!("it.tsv"),
prefer_hans: false,
editing_lang: Some("it-IT"),
catalog: OnceLock::new(),
},
LangInfo { code: "it", prefer_hans: false, editing_lang: Some("it-IT") },
// Hungarian; `hu`, `hu-HU` and `hu_HU.UTF-8` resolve here.
LangInfo { code: "hu", name: "Magyar", source: include_str!("hu.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() },
LangInfo { code: "hu", prefer_hans: false, editing_lang: None },
// French; `fr`, `fr-FR`, `fr_CA` and other regions resolve here.
LangInfo { code: "fr", name: "Français", source: include_str!("fr.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() },
LangInfo { code: "fr", prefer_hans: false, editing_lang: None },
// Bulgarian; `bg`, `bg-BG` and `bg_BG.UTF-8` resolve here.
LangInfo {
code: "bg", name: "Български", source: include_str!("bg.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new()
},
LangInfo { code: "bg", prefer_hans: false, editing_lang: None },
// Vietnamese; `vi`, `vi-VN` and `vi_VN.UTF-8` resolve here.
LangInfo { code: "vi", name: "Tiếng Việt", source: include_str!("vi.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() },
LangInfo { code: "vi", prefer_hans: false, editing_lang: None },
// Swedish; sv, sv-SE, sv-FI and sv_SE.UTF-8 resolve here.
LangInfo { code: "sv", name: "Svenska", source: include_str!("sv.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() },
LangInfo { code: "sv", prefer_hans: false, editing_lang: None },
// Croatian; `hr`, `hr-HR` and `hr_HR.UTF-8` resolve here.
LangInfo { code: "hr", name: "Hrvatski", source: include_str!("hr.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() },
LangInfo { code: "hr", prefer_hans: false, editing_lang: None },
// Dutch; `nl`, `nl-NL`, `nl-BE` and `nl_NL.UTF-8` resolve here.
LangInfo { code: "nl", name: "Nederlands", source: include_str!("nl.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() },
LangInfo { code: "nl", prefer_hans: false, editing_lang: None },
// Danish; `da`, `da-DK` and `da_DK.UTF-8` resolve here.
LangInfo { code: "da", name: "Dansk", source: include_str!("da.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() },
LangInfo { code: "da", prefer_hans: false, editing_lang: None },
// Romanian; `ro`, `ro-RO`, `ro_RO.UTF-8` and `ro-MD` resolve here.
LangInfo { code: "ro", name: "Română", source: include_str!("ro.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() },
LangInfo { code: "ro", prefer_hans: false, editing_lang: None },
// Slovenian; `sl`, `sl-SI` and `sl_SI.UTF-8` resolve here.
LangInfo { code: "sl", name: "Slovenščina", source: include_str!("sl.tsv"), prefer_hans: false, editing_lang: None, catalog: OnceLock::new() },
LangInfo { code: "sl", prefer_hans: false, editing_lang: None },
];
impl LangInfo {
fn catalog(&self) -> &Catalog {
self.catalog.get_or_init(|| {
let (catalog, errors) = Catalog::parse(self.source);
for error in errors {
log::warn!("{} interface catalog: {error}; that entry shows in English", self.code);
}
catalog
})
}
}
/// A language the UI can be shown in (a handle into [`LANGUAGES`]).
/// A language the UI can be shown in: English, or an offered language whose catalogs are embedded.
#[derive(Clone, Copy)]
pub struct Lang(&'static LangInfo);
pub struct Lang {
lang: craft_i18n::Lang,
/// The catalogs it is looked up in: the embedded ones ([`I18N`]), or a test fixture's.
catalogs: Option<&'static I18n>,
}
impl std::fmt::Debug for Lang {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Lang({})", self.0.code)
write!(f, "Lang({})", self.code())
}
}
impl PartialEq for Lang {
fn eq(&self, other: &Self) -> bool {
self.0.code == other.0.code
self.code() == other.code()
}
}
impl Eq for Lang {}
impl Lang {
pub const EN: Lang = Lang(&LANGUAGES[0]);
pub const EN: Lang = Lang { lang: craft_i18n::Lang::EN, catalogs: None };
pub fn code(self) -> &'static str {
self.0.code
fn i18n(self) -> &'static I18n {
self.catalogs.unwrap_or_else(|| LazyLock::force(&I18N))
}
fn info(self) -> Option<&'static LangInfo> {
let code = self.code();
LANGUAGES.iter().find(|l| l.code == code)
}
pub fn code(self) -> &'static str {
self.i18n().code(self.lang)
}
/// The language's name in itself (its `@id language.name` row), shown in the Options dropdown.
pub fn name(self) -> &'static str {
self.0.name
self.i18n().name(self.lang)
}
pub fn prefers_hans(self) -> bool {
self.0.prefer_hans
self.info().is_some_and(|l| l.prefer_hans)
}
pub fn prefers_korean(self) -> bool {
self.0.code == "ko"
self.code() == "ko"
}
/// The language new blank documents are written in with this interface ([`LangInfo::editing_lang`]).
pub fn editing_lang(self) -> Option<&'static str> {
self.0.editing_lang
self.info().and_then(|l| l.editing_lang)
}
/// Is the interface written in CJK script (its own name is), so it needs a CJK face?
pub fn uses_cjk(self) -> bool {
self.0.name.chars().any(|c| ('\u{2E80}'..='\u{9FFF}').contains(&c) || ('\u{AC00}'..='\u{D7AF}').contains(&c))
self.name().chars().any(|c| ('\u{2E80}'..='\u{9FFF}').contains(&c) || ('\u{AC00}'..='\u{D7AF}').contains(&c))
}
/// The embedded catalogs (shared, then WordCraft's) this language reads; none for English.
fn catalog_texts(self) -> impl Iterator<Item = &'static str> {
let code = self.code();
CRAFT_I18N.iter().filter(move |(c, ..)| c.eq_ignore_ascii_case(code)).flat_map(|(_, shared, app)| [*shared, *app])
}
/// The CJK interface text the fonts must cover with the Chinese (`hans`) or other CJK fallback
/// order: the catalogs of the CJK languages using that order, and every language's own name (the
/// Options list). The interface fonts check the embedded faces against it (#486).
pub fn cjk_font_text(hans: bool) -> (Vec<&'static str>, String) {
let catalogs = Lang::all().filter(|l| l.uses_cjk() && l.prefers_hans() == hans).map(|l| l.0.source).collect();
let catalogs = Lang::all().filter(|l| l.uses_cjk() && l.prefers_hans() == hans).flat_map(Lang::catalog_texts).collect();
(catalogs, Lang::all().map(Lang::name).collect::<Vec<_>>().join(" "))
}
/// The CJK interface catalogs using the Korean or Chinese writing-system preference.
pub fn cjk_font_text_for(hans: bool, korean: bool) -> (Vec<&'static str>, String) {
let catalogs = Lang::all().filter(|l| l.uses_cjk() && l.prefers_hans() == hans && l.prefers_korean() == korean).map(|l| l.0.source).collect();
let catalogs =
Lang::all().filter(|l| l.uses_cjk() && l.prefers_hans() == hans && l.prefers_korean() == korean).flat_map(Lang::catalog_texts).collect();
(catalogs, Lang::all().map(Lang::name).collect::<Vec<_>>().join(" "))
}
/// A language by its exact code (any case).
/// A language by its exact code (any case): one of [`LANGUAGES`] whose catalogs are embedded.
pub fn from_code(code: &str) -> Option<Lang> {
LANGUAGES.iter().find(|l| l.code.eq_ignore_ascii_case(code)).map(Lang)
let info = LANGUAGES.iter().find(|l| l.code.eq_ignore_ascii_case(code))?;
I18N.from_code(info.code).map(|lang| Lang { lang, catalogs: None })
}
/// Resolve the `language` setting: a language code, or [`AUTO`] (and anything unknown, e.g. a
@@ -259,9 +219,10 @@ impl Lang {
Lang::from_code(pref).unwrap_or_else(system_lang)
}
/// Every registered language.
/// Every language the interface can be shown in: [`LANGUAGES`] in order, those with embedded
/// catalogs (English only in a build without craft-i18n).
pub fn all() -> impl Iterator<Item = Lang> {
LANGUAGES.iter().map(Lang)
LANGUAGES.iter().filter_map(|l| Lang::from_code(l.code))
}
/// Every registered language in the order a picker lists them: English first (the source
@@ -273,10 +234,6 @@ impl Lang {
rest.sort_by_cached_key(|l| name_sort_key(l.name()));
std::iter::once(Lang::EN).chain(rest).collect()
}
fn catalog(self) -> &'static Catalog {
self.0.catalog()
}
}
/// The canonical `language` setting for user input: [`AUTO`] or a registered code (any case).
@@ -288,31 +245,22 @@ pub fn normalize_pref(pref: &str) -> Option<&'static str> {
Lang::from_code(pref).map(Lang::code)
}
/// Candidate codes for a locale tag, most specific first: `zh_TW.UTF-8` → `zh-tw`, `zh-hant`, `zh`.
fn candidates(tag: &str) -> Vec<String> {
let base = tag.split(['.', '@']).next().unwrap_or("").replace('_', "-").to_ascii_lowercase();
let parts: Vec<&str> = base.split('-').filter(|p| !p.is_empty()).collect();
let Some(&primary) = parts.first() else { return Vec::new() };
let mut out = Vec::new();
for n in (1..=parts.len()).rev() {
out.push(parts.get(..n).unwrap_or_default().join("-"));
}
if primary == "zh" && !parts.iter().any(|p| matches!(*p, "hans" | "hant")) {
// Chinese by region when no script is given.
let script = if parts.iter().any(|p| matches!(*p, "tw" | "hk" | "mo")) { "zh-hant" } else { "zh-hans" };
out.insert(out.len().saturating_sub(1), script.to_string());
}
out
/// Portuguese other than Brazil's (`pt`, `pt-PT`, `pt_AO.UTF-8`): WordCraft has no catalog for
/// it (the European vocabulary differs), so it stays English rather than falling to `pt-br` as the
/// crate's matching would.
fn non_brazilian_portuguese(tag: &str) -> bool {
let tag = tag.trim().to_ascii_lowercase();
let mut parts = tag.split(['.', '@']).next().unwrap_or("").split(['-', '_']);
parts.next() == Some("pt") && !parts.any(|p| p == "br")
}
/// The registered language for a locale tag such as `ja_JP.UTF-8` or `zh-Hant-TW`; `None` when
/// there is no catalog for it. `C`/`POSIX` mean English.
pub fn lang_from_tag(tag: &str) -> Option<Lang> {
let cands = candidates(tag);
if matches!(cands.first().map(String::as_str), Some("c" | "posix")) {
return Some(Lang::EN);
if non_brazilian_portuguese(tag) {
return None;
}
cands.iter().find_map(|c| Lang::from_code(c))
I18N.match_tag(tag).and_then(|lang| Lang::from_code(I18N.code(lang)))
}
/// The first of the system's preferred languages WordCraft has, in the user's order: someone
@@ -368,9 +316,9 @@ pub fn current() -> Lang {
CURRENT.get()
}
/// Does `lang` have a catalog entry for this string? (English never does: it is the source.)
/// Does `lang` have a translation for this string? (English never does: it is the source.)
pub fn has(lang: Lang, s: &str) -> bool {
lang.catalog().plain(s).is_some()
lang.i18n().get(lang.lang, "", s).is_some()
}
/// `s` in the current language ([`tr`] with [`current`]).
@@ -378,8 +326,8 @@ pub fn t(s: &str) -> &str {
tr(current(), s)
}
/// `s` in the current language as worded for `context` (one of `catalog::CONTEXTS`), falling
/// back to the plain translation and then to English.
/// `s` in the current language as worded for `context` (`preview`, `template`), falling back to
/// the plain translation and then to English.
pub fn tc<'a>(context: &str, s: &'a str) -> &'a str {
trc(current(), context, s)
}
@@ -388,9 +336,21 @@ pub fn tc<'a>(context: &str, s: &'a str) -> &'a str {
pub fn trc<'a>(lang: Lang, context: &str, s: &'a str) -> &'a str {
#[cfg(test)]
if pseudo::on() {
return pseudo::mark(s);
return I18N.tc(craft_i18n::Lang::PSEUDO, context, s);
}
lang.catalog().in_context(context, s).unwrap_or(s)
lang.i18n().tc(lang.lang, context, s)
}
/// `s`'s translation for `context` in the current language, with no fallback to the plain row
/// or English: for a word whose plain translation means something else here (the "Open"
/// paragraph-spacing preset is not the file verb).
pub fn get<'a>(context: &str, s: &'a str) -> Option<&'a str> {
#[cfg(test)]
if pseudo::on() {
return I18N.get(craft_i18n::Lang::PSEUDO, context, s);
}
let lang = current();
lang.i18n().get(lang.lang, context, s)
}
/// Lowercase translated interface labels and UI search queries in the current language.
@@ -416,9 +376,9 @@ pub fn lowercase(s: &str) -> String {
pub fn tr(lang: Lang, s: &str) -> &str {
#[cfg(test)]
if pseudo::on() {
return pseudo::mark(s);
return I18N.t(craft_i18n::Lang::PSEUDO, s);
}
lang.catalog().plain(s).unwrap_or(s)
lang.i18n().t(lang.lang, s)
}
/// A style's name as the interface shows it: built-in styles (`Normal`, `Heading 2`, `TOC 3`) in
@@ -460,38 +420,26 @@ pub fn prefixed(prefix: &str, rest: &str) -> String {
fmt(t(&format!("{prefix} {{action}}")), &[("action", t(rest))])
}
/// Fill `{name}` placeholders in one pass. Unknown placeholders stay as written, and inserted
/// values are never read as templates again, so a file name containing `{n}` stays intact.
pub fn fmt(template: &str, args: &[(&str, &str)]) -> String {
let mut out = String::with_capacity(template.len());
let mut rest = template;
while let Some((before, after_open)) = rest.split_once('{') {
out.push_str(before);
let Some((name, after_close)) = after_open.split_once('}') else {
out.push('{');
out.push_str(after_open);
return out;
};
match args.iter().find(|(key, _)| *key == name) {
Some((_, value)) => out.push_str(value),
None => {
out.push('{');
out.push_str(name);
out.push('}');
}
}
rest = after_close;
}
out.push_str(rest);
out
}
/// A test language whose catalog is `source` (catalog format), so tests of lookup code don't
/// depend on the bundled catalogs' wording. `code` selects code-specific rules such as Turkish
/// casing.
/// A test language whose WordCraft catalog is `source` (catalog format), so tests of lookup code
/// don't depend on the embedded catalogs' wording. `code` selects code-specific rules such as
/// Turkish casing.
#[cfg(test)]
pub(crate) fn fixture_lang(code: &'static str, source: &'static str) -> Lang {
Lang(Box::leak(Box::new(LangInfo { code, name: "Fixture", source, prefer_hans: false, editing_lang: None, catalog: OnceLock::new() })))
let embedded: &'static craft_i18n::Embedded = Box::leak(Box::new([(code, "", source)]));
let (i18n, _) = I18n::new(embedded, &[code]);
let i18n: &'static I18n = Box::leak(Box::new(i18n));
let lang = i18n.from_code(code).unwrap_or(craft_i18n::Lang::EN);
Lang { lang, catalogs: Some(i18n) }
}
/// For a test that needs the embedded catalogs: `false`, with a note, when none were embedded
/// (`CRAFT_I18N_DIR` unset), so the test can skip; the interface is then English only.
#[cfg(test)]
pub(crate) fn catalogs_embedded() -> bool {
if CRAFT_I18N.is_empty() {
eprintln!("skipped: built without craft-i18n (CRAFT_I18N_DIR); the interface is English only");
}
!CRAFT_I18N.is_empty()
}
#[cfg(test)]
+8 -15
View File
@@ -1,20 +1,18 @@
//! A test-only pseudo-language: every string that goes through the catalog lookup comes back
//! wrapped in `⟦…⟧`, so text drawn without the marks never went through translation.
//! A test-only pseudo-language: while it is on (per thread), every string that goes through a
//! lookup comes back as the craft-i18n crate's pseudo locale marks it, wrapped in `⟦…⟧`, so text
//! drawn without the marks never went through translation.
use std::cell::{Cell, RefCell};
use std::collections::{BTreeSet, HashMap};
use std::cell::Cell;
use std::collections::BTreeSet;
thread_local! {
static ON: Cell<bool> = const { Cell::new(false) };
// Marked strings live for the test process; lookups hand out `&'static str`.
static CACHE: RefCell<HashMap<String, &'static str>> = RefCell::new(HashMap::new());
// Every English string looked up while on, to check against a real catalog.
static LOOKED_UP: RefCell<BTreeSet<String>> = const { RefCell::new(BTreeSet::new()) };
}
/// Every English string looked up while the pseudo-language was on.
/// Every English string looked up in the pseudo-language so far, in this process (the crate
/// keeps one list for every thread).
pub fn looked_up() -> BTreeSet<String> {
LOOKED_UP.with_borrow(Clone::clone)
super::I18N.looked_up()
}
pub fn on() -> bool {
@@ -25,11 +23,6 @@ pub fn set(on: bool) {
ON.set(on);
}
pub fn mark(s: &str) -> &'static str {
LOOKED_UP.with_borrow_mut(|l| l.insert(s.to_string()));
CACHE.with_borrow_mut(|c| *c.entry(s.to_string()).or_insert_with(|| Box::leak(format!("⟦{}⟧", s.replace('\n', "⟧\n⟦")).into_boxed_str())))
}
/// `text` without its marked segments. Lines are marked one by one, since multi-line labels are
/// often drawn a line at a time.
pub fn unmarked(text: &str) -> String {
+163 -108
View File
@@ -1,14 +1,39 @@
//! Interface language tests (#8).
use super::catalog::{Catalog, parse_entries, placeholders};
use super::*;
fn lang(code: &str) -> Lang {
Lang::from_code(code).unwrap_or_else(|| panic!("{code} registered"))
}
/// Skips a test that needs the embedded catalogs when none were embedded (`CRAFT_I18N_DIR`
/// unset): the interface is then English only.
macro_rules! needs_catalogs {
() => {
if !catalogs_embedded() {
return;
}
};
}
/// The translations in `code`'s embedded WordCraft catalog, plural forms apart: the fonts must
/// cover WordCraft's own wording. (The shared catalog holds every app's; the CJK checks read it too.)
fn translations(code: &str) -> Vec<String> {
let code = lang(code).code();
CRAFT_I18N
.iter()
.filter(|(c, ..)| *c == code)
.flat_map(|(_, _, app)| app.lines())
.filter(|l| !l.starts_with('#'))
.filter_map(|l| l.split('\t').nth(2))
.flat_map(|t| t.split('|'))
.map(String::from)
.collect()
}
#[test]
fn system_locales_map_to_languages() {
needs_catalogs!();
assert_eq!(lang_from_tag("zh_CN.UTF-8"), Some(lang("zh-hans")));
assert_eq!(lang_from_tag("zh-CN"), Some(lang("zh-hans")));
assert_eq!(lang_from_tag("zh"), Some(lang("zh-hans")));
@@ -46,6 +71,7 @@ const UNSUPPORTED: &str = "qaa-QM";
#[test]
fn the_first_supported_preferred_language_wins() {
needs_catalogs!();
// Windows and macOS list several preferred languages in order.
assert_eq!(first_supported([UNSUPPORTED, "zh-Hant-TW", "en-US"]), Some(lang("zh-hant")));
assert_eq!(first_supported(["en-US", "ja-JP"]), Some(Lang::EN));
@@ -58,6 +84,7 @@ fn the_first_supported_preferred_language_wins() {
#[test]
fn settings_resolve_with_fallback() {
needs_catalogs!();
assert_eq!(Lang::from_pref("ja"), lang("ja"));
assert_eq!(Lang::from_pref("ZH-HANS"), lang("zh-hans"));
assert_eq!(Lang::from_pref("en"), Lang::EN);
@@ -79,16 +106,6 @@ fn lookups_fall_back_to_english() {
assert!(has(fx, "File") && !has(fx, "Save") && !has(Lang::EN, "File"));
}
/// Each registered language looks strings up in its own catalog file.
#[test]
fn each_language_reads_its_own_catalog() {
for l in Lang::all() {
for e in parse_entries(l.0.source).0.iter().filter(|e| e.context.is_empty()) {
assert_eq!(tr(l, &e.source), e.translation, "{}", l.code());
}
}
}
#[test]
fn current_language_is_per_thread() {
set_current(fixture_lang(
@@ -117,35 +134,9 @@ fn placeholders_fill_without_reinterpreting_values() {
assert_eq!(fmt(tr(fx, "Page {page} of {pages}"), &[("page", "3"), ("pages", "9")]), "9: 3");
}
#[test]
fn malformed_catalog_lines_are_skipped_not_fatal() {
let (c, errors) =
Catalog::parse("# c\n\tHello\t你好\nctx\tA\tB\n\tOpen…\t打开\n\tPage {n}\t第 {m} 页\nno tabs\n\tHello\t重复\n\tLine\\nTwo\t一\\n二\n");
assert_eq!(errors.len(), 5, "{errors:?}");
assert_eq!(c.plain("Hello"), Some("你好"), "the first entry wins");
assert_eq!(c.plain("Line\nTwo"), Some("一\n二"));
assert_eq!(c.plain("Open…"), None);
}
/// Every bundled catalog parses without errors, and its entries keep the English placeholders.
#[test]
fn bundled_catalogs_are_well_formed() {
let mut codes = std::collections::HashSet::new();
for l in &LANGUAGES {
assert!(codes.insert(l.code), "duplicate code {}", l.code);
assert_eq!(l.code, l.code.to_ascii_lowercase());
let (entries, errors) = parse_entries(l.source);
assert!(errors.is_empty(), "{}: {errors:?}", l.code);
for e in &entries {
assert_eq!(placeholders(&e.source), placeholders(&e.translation), "{}: {:?}", l.code, e.source);
assert_eq!(e.translation.trim(), e.translation, "{}: stray whitespace in {:?}", l.code, e.translation);
assert!(!e.translation.contains("..."), "{}: use … rather than three dots: {:?}", l.code, e.translation);
}
}
}
#[test]
fn the_language_setting_is_a_ui_command_and_persists() {
needs_catalogs!();
let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default());
assert_eq!(app.ui.language, AUTO);
let r = app.run("ui.language", serde_json::json!({"value": "ZH-HANT"})).unwrap();
@@ -168,6 +159,7 @@ fn the_language_setting_is_a_ui_command_and_persists() {
#[test]
fn ukrainian_locales_and_saved_preference_work_without_changing_the_document() {
needs_catalogs!();
let uk = lang("uk");
for tag in ["uk", "uk-UA", "UK_ua.UTF-8", "uk-Cyrl-UA", "uk_UA.UTF-8@euro"] {
assert_eq!(lang_from_tag(tag), Some(uk), "{tag}");
@@ -188,10 +180,10 @@ fn ukrainian_locales_and_saved_preference_work_without_changing_the_document() {
#[test]
fn bundled_interface_fonts_cover_ukrainian_without_system_fallbacks() {
let db = wordcraft_fonts::FontDb::with_font_dirs(Vec::new());
let (entries, errors) = parse_entries(lang("uk").0.source);
assert!(errors.is_empty());
needs_catalogs!();
let entries = translations("uk");
let chars: std::collections::HashSet<char> =
entries.iter().flat_map(|e| e.translation.chars()).filter(|c| ('\u{0400}'..='\u{04ff}').contains(c)).chain("ҐґЄєІіЇї".chars()).collect();
entries.iter().flat_map(|e| e.chars()).filter(|c| ('\u{0400}'..='\u{04ff}').contains(c)).chain("ҐґЄєІіЇї".chars()).collect();
for (family, style) in [("Inter", "Regular"), ("Inter", "Medium"), ("Inter", "SemiBold"), ("JetBrains Mono", "Regular")] {
let face = db.face(family, style);
assert_eq!(face.family, family, "must use the bundled interface face");
@@ -204,10 +196,9 @@ fn bundled_interface_fonts_cover_ukrainian_without_system_fallbacks() {
#[test]
fn bundled_interface_fonts_cover_serbian_cyrillic_without_system_fallbacks() {
let db = wordcraft_fonts::FontDb::with_font_dirs(Vec::new());
let (entries, errors) = parse_entries(lang("sr").0.source);
assert!(errors.is_empty());
let chars: std::collections::HashSet<char> =
entries.iter().flat_map(|e| e.translation.chars()).filter(|c| ('\u{0400}'..='\u{04ff}').contains(c)).collect();
needs_catalogs!();
let entries = translations("sr");
let chars: std::collections::HashSet<char> = entries.iter().flat_map(|e| e.chars()).filter(|c| ('\u{0400}'..='\u{04ff}').contains(c)).collect();
for (family, style) in [("Inter", "Regular"), ("Inter", "Medium"), ("Inter", "SemiBold"), ("JetBrains Mono", "Regular")] {
let face = db.face(family, style);
assert_eq!(face.family, family, "must use the bundled interface face");
@@ -220,12 +211,11 @@ fn bundled_interface_fonts_cover_serbian_cyrillic_without_system_fallbacks() {
#[test]
fn bundled_interface_fonts_cover_serbian_latin_without_system_fallbacks() {
let db = wordcraft_fonts::FontDb::with_font_dirs(Vec::new());
let (entries, errors) = parse_entries(lang("sr-latn").0.source);
assert!(errors.is_empty());
needs_catalogs!();
let entries = translations("sr-latn");
// Serbian Latin's letters beyond plain ASCII: š č ć ž đ (and their capitals).
let extra = ['š', 'č', 'ć', 'ž', 'đ', 'Š', 'Č', 'Ć', 'Ž', 'Đ'];
let chars: std::collections::HashSet<char> =
entries.iter().flat_map(|e| e.translation.chars()).filter(|c| extra.contains(c)).chain(extra).collect();
let chars: std::collections::HashSet<char> = entries.iter().flat_map(|e| e.chars()).filter(|c| extra.contains(c)).chain(extra).collect();
for (family, style) in [("Inter", "Regular"), ("Inter", "Medium"), ("Inter", "SemiBold"), ("JetBrains Mono", "Regular")] {
let face = db.face(family, style);
assert_eq!(face.family, family, "must use the bundled interface face");
@@ -237,6 +227,7 @@ fn bundled_interface_fonts_cover_serbian_latin_without_system_fallbacks() {
#[test]
fn serbian_locales_resolve_by_script() {
needs_catalogs!();
let cyr = lang("sr");
let lat = lang("sr-latn");
for tag in ["sr", "sr-RS", "sr_RS.UTF-8", "SR", "sr-Cyrl", "sr-Cyrl-RS"] {
@@ -250,13 +241,17 @@ fn serbian_locales_resolve_by_script() {
#[test]
fn brazilian_portuguese_locales_and_saved_preference_work_without_changing_the_document() {
needs_catalogs!();
let pt = lang("pt-br");
for tag in ["pt-BR", "pt_BR.UTF-8", "PT-BR", "pt-BR-latn", "pt_BR.UTF-8@euro"] {
assert_eq!(lang_from_tag(tag), Some(pt), "{tag}");
}
// European Portuguese and a bare `pt` have no catalog yet, so they follow the fallback.
assert_eq!(lang_from_tag("pt-PT"), None);
assert_eq!(lang_from_tag("pt"), None);
// European Portuguese and a bare `pt` have no catalog yet, so they follow the fallback
// rather than the crate's `pt-br` match.
for tag in ["pt-PT", "pt", "pt_PT.UTF-8", "pt-AO", "PT_pt"] {
assert_eq!(lang_from_tag(tag), None, "{tag}");
}
assert_eq!(first_supported(["pt-PT", "fr-FR"]), Some(lang("fr")));
assert_eq!(first_supported([UNSUPPORTED, "pt-BR", "en-US"]), Some(pt));
let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default());
let before = serde_json::to_value(&app.session.doc).unwrap();
@@ -273,11 +268,11 @@ fn brazilian_portuguese_locales_and_saved_preference_work_without_changing_the_d
#[test]
fn bundled_interface_fonts_cover_brazilian_portuguese_without_system_fallbacks() {
let db = wordcraft_fonts::FontDb::with_font_dirs(Vec::new());
let (entries, errors) = parse_entries(lang("pt-br").0.source);
assert!(errors.is_empty());
needs_catalogs!();
let entries = translations("pt-br");
let chars: std::collections::HashSet<char> = entries
.iter()
.flat_map(|e| e.translation.chars())
.flat_map(|e| e.chars())
// The accented Latin letters Portuguese adds on top of ASCII; symbols such as ¶, ⌘ or 📂
// come from the English sources and render as they already do.
.filter(|c| ('\u{00C0}'..='\u{017F}').contains(c))
@@ -294,6 +289,7 @@ fn bundled_interface_fonts_cover_brazilian_portuguese_without_system_fallbacks()
#[test]
fn czech_locales_and_saved_preference_work_without_changing_the_document() {
needs_catalogs!();
let cs = lang("cs");
for tag in ["cs", "cs-CZ", "cs_CZ.UTF-8", "CS", "cs-Latn-CZ", "cs_CZ.UTF-8@euro"] {
assert_eq!(lang_from_tag(tag), Some(cs), "{tag}");
@@ -314,11 +310,11 @@ fn czech_locales_and_saved_preference_work_without_changing_the_document() {
#[test]
fn bundled_interface_fonts_cover_czech_without_system_fallbacks() {
let db = wordcraft_fonts::FontDb::with_font_dirs(Vec::new());
let (entries, errors) = parse_entries(lang("cs").0.source);
assert!(errors.is_empty());
needs_catalogs!();
let entries = translations("cs");
let chars: std::collections::HashSet<char> = entries
.iter()
.flat_map(|e| e.translation.chars())
.flat_map(|e| e.chars())
.filter(|c| ('\u{00C0}'..='\u{017F}').contains(c))
.chain("ÁáČčĎďÉéĚěÍíŇňÓóŘřŠšŤťÚúŮůÝýŽž".chars())
.collect();
@@ -333,6 +329,7 @@ fn bundled_interface_fonts_cover_czech_without_system_fallbacks() {
#[test]
fn chinese_interface_fonts_load_and_cover_simplified_hanzi() {
needs_catalogs!();
// #241: without an embedded Chinese face the interface adds an installed CJK font; egui must
// accept it (it panics on font data it can't parse) and draw simplified-only hanzi with it.
let ctx = egui::Context::default();
@@ -348,6 +345,7 @@ fn chinese_interface_fonts_load_and_cover_simplified_hanzi() {
#[test]
fn korean_interface_fonts_load_and_cover_hangul() {
needs_catalogs!();
let ctx = egui::Context::default();
ctx.set_fonts(crate::theme::font_definitions_language(false, true, true));
ctx.run_ui(Default::default(), |_| {}).textures_delta.clear();
@@ -372,24 +370,26 @@ fn cjk_catalogs_are_covered_by_the_embedded_interface_faces() {
eprintln!("skipped: built without craft-fonts (CRAFT_FONTS_DIR)");
return;
}
needs_catalogs!();
let names: String = Lang::all().map(Lang::name).collect();
for code in ["zh-hans", "zh-hant", "ja", "ko"] {
let l = lang(code);
let ctx = egui::Context::default();
ctx.set_fonts(crate::theme::font_definitions_language(l.prefers_hans(), l.prefers_korean(), false));
ctx.run_ui(Default::default(), |_| {}).textures_delta.clear();
let (entries, errors) = parse_entries(l.0.source);
assert!(errors.is_empty(), "{code}: {errors:?}");
let entries = translations(code);
let chars: std::collections::BTreeSet<char> =
entries.iter().flat_map(|e| e.translation.chars()).chain(names.chars()).filter(|c| wordcraft_fonts::is_cjk(*c)).collect();
entries.iter().flat_map(|e| e.chars()).chain(names.chars()).filter(|c| wordcraft_fonts::is_cjk(*c)).collect();
assert!(chars.len() > 300, "{code}: {} CJK characters", chars.len());
for font in [crate::theme::regular(14.0), crate::theme::medium(14.0), crate::theme::semibold(14.0)] {
let missing: String = ctx.fonts_mut(|f| chars.iter().filter(|c| !f.has_glyph(&font, **c)).collect());
assert!(missing.is_empty(), "{code}: no glyph in {font:?} for {missing}");
}
// So the interface reads no installed CJK font for these languages.
let mut texts: Vec<&str> = l.catalog_texts().collect();
texts.push(&names);
assert_eq!(
wordcraft_fonts::ui_uncovered_cjk(&[l.0.source, &names], &wordcraft_fonts::ui_cjk_fonts_for(l.prefers_hans(), l.prefers_korean())),
wordcraft_fonts::ui_uncovered_cjk(&texts, &wordcraft_fonts::ui_cjk_fonts_for(l.prefers_hans(), l.prefers_korean())),
Vec::<char>::new()
);
}
@@ -397,6 +397,7 @@ fn cjk_catalogs_are_covered_by_the_embedded_interface_faces() {
#[test]
fn only_cjk_languages_ask_for_an_installed_cjk_font() {
needs_catalogs!();
// #241 follow-up: an English interface must not read a large system CJK font on startup.
let cjk: Vec<&str> = Lang::all().filter(|l| l.uses_cjk()).map(Lang::code).collect();
assert_eq!(cjk, ["zh-hans", "zh-hant", "ja", "ko"]);
@@ -404,6 +405,7 @@ fn only_cjk_languages_ask_for_an_installed_cjk_font() {
#[test]
fn russian_locales_and_saved_preference_work_without_changing_the_document() {
needs_catalogs!();
let ru = lang("ru");
for tag in ["ru", "ru-RU", "ru_RU.UTF-8", "RU-ru", "ru_UA.UTF-8", "ru-Cyrl-RU"] {
assert_eq!(lang_from_tag(tag), Some(ru), "{tag}");
@@ -424,11 +426,11 @@ fn russian_locales_and_saved_preference_work_without_changing_the_document() {
#[test]
fn bundled_interface_fonts_cover_russian_without_system_fallbacks() {
let db = wordcraft_fonts::FontDb::with_font_dirs(Vec::new());
let (entries, errors) = parse_entries(lang("ru").0.source);
assert!(errors.is_empty());
needs_catalogs!();
let entries = translations("ru");
let chars: std::collections::HashSet<char> = entries
.iter()
.flat_map(|e| e.translation.chars())
.flat_map(|e| e.chars())
// Cyrillic letters plus the Russian quotes and dash; symbols such as ¶, ⌘ or 📂 come from
// the English sources and render as they already do.
.filter(|c| ('\u{0400}'..='\u{04FF}').contains(c) || matches!(c, '«' | '»' | '—'))
@@ -471,15 +473,7 @@ fn built_in_style_names_are_translated_for_display_and_user_styles_are_not() {
}
#[test]
fn context_entries_override_only_their_context_and_unknown_contexts_are_rejected() {
let (c, errors) = Catalog::parse("\tTitle\tЗаголовок\npreview\tTitle\tТитул\n\tFont\tШрифт\nnope\tFont\tX\n");
assert_eq!(errors.len(), 1, "{errors:?}");
assert!(errors[0].contains("unknown context"));
assert_eq!(c.plain("Title"), Some("Заголовок"));
assert_eq!(c.in_context("preview", "Title"), Some("Титул"));
// Without a context entry, a context lookup uses the plain one.
assert_eq!(c.in_context("preview", "Font"), Some("Шрифт"));
assert_eq!(c.in_context("preview", "Missing"), None);
fn context_entries_override_only_their_context() {
let fx = fixture_lang("x-fixture", "\tTitle\tTitle-FX\npreview\tTitle\tSample-FX\n");
assert_eq!(tr(fx, "Title"), "Title-FX");
assert_eq!(trc(fx, "preview", "Title"), "Sample-FX");
@@ -490,6 +484,7 @@ fn context_entries_override_only_their_context_and_unknown_contexts_are_rejected
#[test]
fn estonian_locales_and_saved_preference_keep_document_content() {
needs_catalogs!();
let et = lang("et");
for tag in ["et", "et-EE", "ET_ee.UTF-8", "et-Latn-EE", "et_EE.UTF-8@euro"] {
assert_eq!(lang_from_tag(tag), Some(et), "{tag}");
@@ -522,6 +517,7 @@ fn bundled_interface_fonts_cover_estonian_letters() {
#[test]
fn turkish_locales_and_saved_preference_work_without_changing_the_document() {
needs_catalogs!();
let tr = lang("tr");
for tag in ["tr", "tr-TR", "TR_tr.UTF-8", "tr-CY", "tr-Latn-TR", "tr_TR.UTF-8@euro"] {
assert_eq!(lang_from_tag(tag), Some(tr), "{tag}");
@@ -548,11 +544,10 @@ fn turkish_locales_and_saved_preference_work_without_changing_the_document() {
#[test]
fn bundled_interface_fonts_cover_turkish_without_system_fallbacks() {
let db = wordcraft_fonts::FontDb::with_font_dirs(Vec::new());
let (entries, errors) = parse_entries(lang("tr").0.source);
assert!(errors.is_empty());
needs_catalogs!();
let entries = translations("tr");
let extra = "ÇçĞğİıÖöŞşÜü";
let chars: std::collections::HashSet<char> =
entries.iter().flat_map(|e| e.translation.chars()).filter(|c| extra.contains(*c)).chain(extra.chars()).collect();
let chars: std::collections::HashSet<char> = entries.iter().flat_map(|e| e.chars()).filter(|c| extra.contains(*c)).chain(extra.chars()).collect();
for (family, style) in [("Inter", "Regular"), ("Inter", "Medium"), ("Inter", "SemiBold"), ("JetBrains Mono", "Regular")] {
let face = db.face(family, style);
assert_eq!(face.family, family, "must use the bundled interface face");
@@ -565,6 +560,7 @@ fn bundled_interface_fonts_cover_turkish_without_system_fallbacks() {
#[test]
fn hungarian_locales_and_saved_preference_work_without_changing_the_document() {
needs_catalogs!();
let hu = lang("hu");
for tag in ["hu", "hu-HU", "hu_HU.UTF-8", "HU_hu.UTF-8@euro", "hu-Latn-HU"] {
assert_eq!(lang_from_tag(tag), Some(hu), "{tag}");
@@ -587,11 +583,10 @@ fn hungarian_locales_and_saved_preference_work_without_changing_the_document() {
#[test]
fn bundled_interface_fonts_cover_hungarian_without_system_fallbacks() {
let db = wordcraft_fonts::FontDb::with_font_dirs(Vec::new());
let (entries, errors) = parse_entries(lang("hu").0.source);
assert!(errors.is_empty());
needs_catalogs!();
let entries = translations("hu");
let extra = "ÁáÉéÍíÓóÖöŐőÚúÜüŰű";
let chars: std::collections::HashSet<char> =
entries.iter().flat_map(|e| e.translation.chars()).filter(|c| extra.contains(*c)).chain(extra.chars()).collect();
let chars: std::collections::HashSet<char> = entries.iter().flat_map(|e| e.chars()).filter(|c| extra.contains(*c)).chain(extra.chars()).collect();
for (family, style) in [("Inter", "Regular"), ("Inter", "Medium"), ("Inter", "SemiBold"), ("JetBrains Mono", "Regular")] {
let face = db.face(family, style);
assert_eq!(face.family, family, "must use the bundled interface face");
@@ -604,6 +599,7 @@ fn bundled_interface_fonts_cover_hungarian_without_system_fallbacks() {
#[test]
fn french_locales_and_saved_preference_work_without_changing_the_document() {
needs_catalogs!();
let fr = lang("fr");
for tag in ["fr", "fr-FR", "FR_fr.UTF-8", "fr-CA", "fr_BE.UTF-8@euro", "fr-CH", "fr-Latn-FR"] {
assert_eq!(lang_from_tag(tag), Some(fr), "{tag}");
@@ -630,12 +626,12 @@ fn french_locales_and_saved_preference_work_without_changing_the_document() {
#[test]
fn bundled_interface_fonts_cover_french_without_system_fallbacks() {
let db = wordcraft_fonts::FontDb::with_font_dirs(Vec::new());
let (entries, errors) = parse_entries(lang("fr").0.source);
assert!(errors.is_empty());
needs_catalogs!();
let entries = translations("fr");
// French letters and punctuation: accents, ligatures, guillemets and the typographic apostrophe.
let extra = "ÀàÂâÆæÇçÉéÈèÊêËëÎîÏïÔôŒœÙùÛûÜüŸÿ«»’";
let chars: std::collections::HashSet<char> =
entries.iter().flat_map(|e| e.translation.chars()).filter(|c| ('\u{00C0}'..='\u{017F}').contains(c)).chain(extra.chars()).collect();
entries.iter().flat_map(|e| e.chars()).filter(|c| ('\u{00C0}'..='\u{017F}').contains(c)).chain(extra.chars()).collect();
for (family, style) in [("Inter", "Regular"), ("Inter", "Medium"), ("Inter", "SemiBold"), ("JetBrains Mono", "Regular")] {
let face = db.face(family, style);
assert_eq!(face.family, family, "must use the bundled interface face");
@@ -656,6 +652,7 @@ fn bundled_interface_fonts_cover_french_without_system_fallbacks() {
#[test]
fn polish_locales_and_saved_preference_work_without_changing_the_document() {
needs_catalogs!();
let pl = lang("pl");
for tag in ["pl", "pl-PL", "pl_PL.UTF-8", "PL-pl", "pl_PL.UTF-8@euro", "pl-Latn-PL"] {
assert_eq!(lang_from_tag(tag), Some(pl), "{tag}");
@@ -676,12 +673,12 @@ fn polish_locales_and_saved_preference_work_without_changing_the_document() {
#[test]
fn bundled_interface_fonts_cover_polish_without_system_fallbacks() {
let db = wordcraft_fonts::FontDb::with_font_dirs(Vec::new());
let (entries, errors) = parse_entries(lang("pl").0.source);
assert!(errors.is_empty(), "{errors:?}");
needs_catalogs!();
let entries = translations("pl");
// Polish letters beyond ASCII, both cases, and the Polish quotation marks.
let polish = "ĄąĆćĘꣳŃńÓóŚśŹźŻż„”";
let chars: std::collections::HashSet<char> =
entries.iter().flat_map(|e| e.translation.chars()).filter(|c| polish.contains(*c)).chain(polish.chars()).collect();
entries.iter().flat_map(|e| e.chars()).filter(|c| polish.contains(*c)).chain(polish.chars()).collect();
for (family, style) in [("Inter", "Regular"), ("Inter", "Medium"), ("Inter", "SemiBold"), ("JetBrains Mono", "Regular")] {
let face = db.face(family, style);
assert_eq!(face.family, family, "must use the bundled interface face");
@@ -693,6 +690,7 @@ fn bundled_interface_fonts_cover_polish_without_system_fallbacks() {
#[test]
fn a_polish_interface_writes_new_documents_in_polish() {
needs_catalogs!();
let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default());
let ctx = egui::Context::default();
let frame = |app: &mut crate::WordApp| ctx.run_ui(egui::RawInput::default(), |ui| app.logic(ui.ctx())).drop_without_applying_deltas();
@@ -724,6 +722,7 @@ fn a_polish_interface_writes_new_documents_in_polish() {
#[test]
fn a_czech_interface_writes_new_documents_and_plain_text_in_czech() {
needs_catalogs!();
let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default());
let ctx = egui::Context::default();
let frame = |app: &mut crate::WordApp| ctx.run_ui(egui::RawInput::default(), |ui| app.logic(ui.ctx())).drop_without_applying_deltas();
@@ -753,6 +752,7 @@ fn a_czech_interface_writes_new_documents_and_plain_text_in_czech() {
#[test]
fn an_italian_interface_writes_new_documents_in_italian() {
needs_catalogs!();
let mut app = crate::WordApp::new(wordcraft_engine::Session::new(wordcraft_doc::Document::new()), Default::default());
let ctx = egui::Context::default();
let frame = |app: &mut crate::WordApp| ctx.run_ui(egui::RawInput::default(), |ui| app.logic(ui.ctx())).drop_without_applying_deltas();
@@ -775,12 +775,13 @@ fn an_italian_interface_writes_new_documents_in_italian() {
#[test]
fn the_editing_languages_are_the_ones_wordcraft_can_proof() {
let with: Vec<(&str, &str)> = Lang::all().filter_map(|l| l.editing_lang().map(|e| (l.code(), e))).collect();
let with: Vec<(&str, &str)> = LANGUAGES.iter().filter_map(|l| l.editing_lang.map(|e| (l.code, e))).collect();
assert_eq!(with, [("pt-br", "pt-BR"), ("pl", "pl-PL"), ("cs", "cs-CZ"), ("tr", "tr-TR"), ("it", "it-IT")]);
}
#[test]
fn german_locales_and_saved_preference_keep_document_content() {
needs_catalogs!();
let de = lang("de");
for tag in ["de", "de-DE", "de-AT", "de-CH", "DE_de.UTF-8", "de_DE.UTF-8@euro", "de-Latn-DE"] {
assert_eq!(lang_from_tag(tag), Some(de), "{tag}");
@@ -813,6 +814,7 @@ fn bundled_interface_fonts_cover_german_letters() {
#[test]
fn italian_locales_and_saved_preference_keep_document_content() {
needs_catalogs!();
let it = lang("it");
for tag in ["it", "it-IT", "it_IT.UTF-8", "it-CH", "it_IT.UTF-8@euro"] {
assert_eq!(lang_from_tag(tag), Some(it), "{tag}");
@@ -845,6 +847,7 @@ fn bundled_interface_fonts_cover_italian_letters() {
#[test]
fn bulgarian_locales_and_saved_preference_work_without_changing_the_document() {
needs_catalogs!();
let bg = lang("bg");
for tag in ["bg", "bg-BG", "bg_BG.UTF-8", "BG-bg", "bg-Cyrl-BG"] {
assert_eq!(lang_from_tag(tag), Some(bg), "{tag}");
@@ -866,11 +869,11 @@ fn bulgarian_locales_and_saved_preference_work_without_changing_the_document() {
#[test]
fn bundled_interface_fonts_cover_bulgarian_without_system_fallbacks() {
let db = wordcraft_fonts::FontDb::with_font_dirs(Vec::new());
let (entries, errors) = parse_entries(lang("bg").0.source);
assert!(errors.is_empty());
needs_catalogs!();
let entries = translations("bg");
let chars: std::collections::HashSet<char> = entries
.iter()
.flat_map(|e| e.translation.chars())
.flat_map(|e| e.chars())
// Cyrillic letters plus the Bulgarian quotes and dash; symbols come from the English sources.
.filter(|c| ('\u{0400}'..='\u{04FF}').contains(c) || matches!(c, '„' | '“' | '—' | '–'))
.chain("АБВГДЕЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЬЮЯабвгдежзийклмнопрстуфхцчшщъьюя".chars())
@@ -887,6 +890,7 @@ fn bundled_interface_fonts_cover_bulgarian_without_system_fallbacks() {
#[test]
fn croatian_locales_and_saved_preference_work_without_changing_the_document() {
needs_catalogs!();
let hr = lang("hr");
for tag in ["hr", "hr-HR", "hr_HR.UTF-8", "HR-hr", "hr-Latn-HR", "hr-BA"] {
assert_eq!(lang_from_tag(tag), Some(hr), "{tag}");
@@ -908,11 +912,11 @@ fn croatian_locales_and_saved_preference_work_without_changing_the_document() {
#[test]
fn bundled_interface_fonts_cover_croatian_without_system_fallbacks() {
let db = wordcraft_fonts::FontDb::with_font_dirs(Vec::new());
let (entries, errors) = parse_entries(lang("hr").0.source);
assert!(errors.is_empty());
needs_catalogs!();
let entries = translations("hr");
let chars: std::collections::HashSet<char> = entries
.iter()
.flat_map(|e| e.translation.chars())
.flat_map(|e| e.chars())
// Croatian letters plus its quotes and dashes; symbols come from the English sources.
.filter(|c| matches!(c, '„' | '”' | '–'))
.chain("ČčĆćĐ𩹮ž".chars())
@@ -929,6 +933,7 @@ fn bundled_interface_fonts_cover_croatian_without_system_fallbacks() {
#[test]
fn swedish_locales_and_saved_preference_work_without_changing_the_document() {
needs_catalogs!();
let sv = lang("sv");
for tag in ["sv", "sv-SE", "sv_SE.UTF-8", "SV-se", "sv-FI"] {
assert_eq!(lang_from_tag(tag), Some(sv), "{tag}");
@@ -950,11 +955,11 @@ fn swedish_locales_and_saved_preference_work_without_changing_the_document() {
#[test]
fn bundled_interface_fonts_cover_swedish_letters() {
let db = wordcraft_fonts::FontDb::with_font_dirs(Vec::new());
let (entries, errors) = parse_entries(lang("sv").0.source);
assert!(errors.is_empty());
needs_catalogs!();
let entries = translations("sv");
let chars: std::collections::HashSet<char> = entries
.iter()
.flat_map(|e| e.translation.chars())
.flat_map(|e| e.chars())
// Latin letters with diacritics plus the Swedish quote and dash; symbols come from the English sources.
.filter(|c| ('\u{00C0}'..='\u{00FF}').contains(c) || matches!(c, '”' | '–'))
.chain("ÅÄÖåäöÉé".chars())
@@ -971,6 +976,7 @@ fn bundled_interface_fonts_cover_swedish_letters() {
#[test]
fn vietnamese_locales_and_saved_preference_work_without_changing_the_document() {
needs_catalogs!();
let vi = lang("vi");
for tag in ["vi", "vi-VN", "vi_VN.UTF-8", "VI-vi", "vi-Latn-VN", "vi-US"] {
assert_eq!(lang_from_tag(tag), Some(vi), "{tag}");
@@ -993,15 +999,15 @@ fn vietnamese_locales_and_saved_preference_work_without_changing_the_document()
#[test]
fn bundled_interface_fonts_cover_vietnamese_letters() {
let db = wordcraft_fonts::FontDb::with_font_dirs(Vec::new());
let (entries, errors) = parse_entries(lang("vi").0.source);
assert!(errors.is_empty(), "{errors:?}");
needs_catalogs!();
let entries = translations("vi");
// Cover the catalog's Latin letters and combining marks, and all 134 non-ASCII Vietnamese
// letters even when a letter is not used by the current translations. U+1EA0..U+1EF9 contains
// the Vietnamese vowel/tone combinations; the other forms are in earlier Latin blocks.
let extra = "ÀàÁáÃãÈèÉéÌìÍíÒòÓóÕõÙùÚúÝýÂâĂăĐđÊêÔôƠơƯưĨĩŨũ";
let chars: std::collections::HashSet<char> = entries
.iter()
.flat_map(|e| e.translation.chars())
.flat_map(|e| e.chars())
.filter(|c| ('\u{00C0}'..='\u{024F}').contains(c) || ('\u{0300}'..='\u{036F}').contains(c) || ('\u{1E00}'..='\u{1EFF}').contains(c))
.chain(extra.chars())
.chain('\u{1EA0}'..='\u{1EF9}')
@@ -1020,17 +1026,18 @@ fn bundled_interface_fonts_cover_vietnamese_letters() {
#[test]
fn spooled_print_receipt_is_translated() {
use crate::printing::{Feedback, feedback};
let (c, errors) = Catalog::parse(
let fx = fixture_lang(
"x-fixture",
"\tPrint job submitted: spooled {count} sheet(s)\tSpooled-FX: {count}\n\tPrint job submitted: {receipt}\tSubmitted-FX: {receipt}\n",
);
assert!(errors.is_empty(), "{errors:?}");
let receipt = || Feedback::Submitted("spooled 2 sheet(s)".into());
assert_eq!(feedback(&receipt(), |s| c.plain(s).unwrap_or(s).to_owned()), "Spooled-FX: 2");
assert_eq!(feedback(&receipt(), |s| tr(fx, s).to_owned()), "Spooled-FX: 2");
assert_eq!(feedback(&receipt(), str::to_owned), "Print job submitted: spooled 2 sheet(s)");
}
#[test]
fn romanian_locales_and_saved_preference_work_without_changing_the_document() {
needs_catalogs!();
let ro = lang("ro");
for tag in ["ro", "ro-RO", "ro_RO.UTF-8", "RO-ro", "ro-Latn-RO", "ro-MD"] {
assert_eq!(lang_from_tag(tag), Some(ro), "{tag}");
@@ -1053,12 +1060,11 @@ fn romanian_locales_and_saved_preference_work_without_changing_the_document() {
#[test]
fn bundled_interface_fonts_cover_romanian_without_system_fallbacks() {
let db = wordcraft_fonts::FontDb::with_font_dirs(Vec::new());
let (entries, errors) = parse_entries(lang("ro").0.source);
assert!(errors.is_empty());
needs_catalogs!();
let entries = translations("ro");
// Romanian's letters beyond plain ASCII (comma-below ș ț) and its quotation marks.
let extra = ['ă', 'â', 'î', 'ș', 'ț', 'Ă', 'Â', 'Î', 'Ș', 'Ț', '„', '”'];
let chars: std::collections::HashSet<char> =
entries.iter().flat_map(|e| e.translation.chars()).filter(|c| extra.contains(c)).chain(extra).collect();
let chars: std::collections::HashSet<char> = entries.iter().flat_map(|e| e.chars()).filter(|c| extra.contains(c)).chain(extra).collect();
for (family, style) in [("Inter", "Regular"), ("Inter", "Medium"), ("Inter", "SemiBold"), ("JetBrains Mono", "Regular")] {
let face = db.face(family, style);
assert_eq!(face.family, family, "must use the bundled interface face");
@@ -1070,6 +1076,7 @@ fn bundled_interface_fonts_cover_romanian_without_system_fallbacks() {
#[test]
fn slovenian_locales_and_saved_preference_work_without_changing_the_document() {
needs_catalogs!();
let sl = lang("sl");
for tag in ["sl", "sl-SI", "sl_SI.UTF-8", "SL-si", "sl-Latn-SI"] {
assert_eq!(lang_from_tag(tag), Some(sl), "{tag}");
@@ -1089,6 +1096,7 @@ fn slovenian_locales_and_saved_preference_work_without_changing_the_document() {
#[test]
fn bundled_interface_fonts_cover_slovenian_letters() {
needs_catalogs!();
let db = wordcraft_fonts::FontDb::with_font_dirs(Vec::new());
for (family, style) in [("Inter", "Regular"), ("Inter", "Medium"), ("Inter", "SemiBold"), ("JetBrains Mono", "Regular")] {
let face = db.face(family, style);
@@ -1099,3 +1107,50 @@ fn bundled_interface_fonts_cover_slovenian_letters() {
}
assert!(!lang("sl").uses_cjk());
}
/// The "Open" paragraph-spacing preset is roomy spacing, not the file verb: only a `spacing` row
/// translates it.
#[test]
fn a_context_lookup_never_borrows_the_plain_translation() {
set_current(fixture_lang("x-fixture", "\tOpen\tOpen-file-FX\n"));
assert_eq!(t("Open"), "Open-file-FX");
assert_eq!(get("spacing", "Open"), None);
set_current(fixture_lang("x-fixture", "\tOpen\tOpen-file-FX\nspacing\tOpen\tRoomy-FX\n"));
assert_eq!(get("spacing", "Open"), Some("Roomy-FX"));
set_current(Lang::EN);
assert_eq!(get("spacing", "Open"), None);
}
/// WordCraft offers exactly the languages in `LANGUAGES` when craft-i18n is embedded, and English
/// alone when it isn't: a missing catalog never fails, it shows English.
#[test]
fn the_languages_offered_are_the_embedded_ones_of_the_list() {
let all: Vec<&str> = Lang::all().map(Lang::code).collect();
if CRAFT_I18N.is_empty() {
assert_eq!(all, ["en"]);
assert_eq!(Lang::from_code("fr"), None);
assert_eq!(Lang::from_pref("fr"), Lang::EN, "a saved language without a catalog shows English");
assert_eq!(normalize_pref("fr"), None);
assert_eq!(tr(Lang::EN, "File"), "File");
return;
}
let listed: Vec<&str> = LANGUAGES.iter().map(|l| l.code).collect();
assert_eq!(all, listed, "every language WordCraft lists has its catalog in craft-i18n");
assert!(Lang::from_code("pseudo").is_none(), "the pseudo locale is never a setting");
}
/// The flatpak manifest builds with the craft-i18n commit `craft-i18n.rev` pins (CI and releases
/// read the file; the manifest has to name it itself).
#[test]
fn the_flatpak_manifest_pins_the_craft_i18n_rev() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
let rev = std::fs::read_to_string(root.join("craft-i18n.rev")).unwrap();
let rev = rev.trim();
assert!(rev.len() == 40 && rev.bytes().all(|b| b.is_ascii_hexdigit()), "craft-i18n.rev holds one full SHA: {rev:?}");
let manifest = std::fs::read_to_string(root.join("packaging/linux/flatpak/ai.storyteller.wordcraft.yml")).unwrap();
let source = manifest.split("url: https://github.com/storytold/craft-i18n").nth(1).expect("a craft-i18n git source");
let source = source.split("- type:").next().unwrap_or(source);
let commit = source.lines().find_map(|l| l.trim().strip_prefix("commit:")).expect("the craft-i18n source pins a commit");
assert_eq!(commit.trim().trim_matches(['\'', '"']), rev);
assert!(manifest.contains("CRAFT_I18N_DIR: /run/build/wordcraft/craft-i18n"), "the build embeds the catalogs");
}
+12
View File
@@ -878,6 +878,9 @@ mod tests {
/// label. AccessKit must still expose the full name and the row must keep its fixed size.
#[test]
fn options_nav_labels_stay_inside_their_rows() {
if !crate::i18n::catalogs_embedded() {
return;
}
for language in ["en", "ru"] {
for percent in [100, 125, 150] {
let mut h = nav_harness(language, percent);
@@ -921,6 +924,9 @@ mod tests {
/// backgrounds intact, and the selected pane's settings visible at every interface size.
#[test]
fn options_nav_rows_keep_hover_and_selection() {
if !crate::i18n::catalogs_embedded() {
return;
}
for language in ["en", "ru"] {
for percent in [100, 125, 150] {
let mut h = nav_harness(language, percent);
@@ -1210,6 +1216,9 @@ mod tests {
h.get_by_label("Animate the caret (it glides and fades)").click();
h.run_steps(4);
assert!(h.state().ui.caret_animation, "and on again");
if !crate::i18n::catalogs_embedded() {
return;
}
h.state_mut().run("ui.language", json!({"value": "es"})).unwrap();
h.run_steps(4);
assert!(h.query_by_label("Animación").is_some() && h.query_by_label("Animation").is_none());
@@ -1252,6 +1261,9 @@ mod tests {
/// takes the unit typed either way.
#[test]
fn length_units_follow_the_interface_language() {
if !crate::i18n::catalogs_embedded() {
return;
}
let mut a = app();
a.run("ui.language", json!({"value": "ru"})).unwrap();
a.run("file.options", json!({"region": "ru-RU"})).unwrap();
+12
View File
@@ -1647,6 +1647,12 @@ fn design(app: &mut WordApp, ui: &mut Ui) {
("Relaxed", "relaxed"),
("Double", "double"),
] {
// "Open" here is roomy spacing, not the file verb: only a `spacing` row
// translates it, else it stays English.
if l == "Open" {
mi_text(ui, app, crate::i18n::get("spacing", l).unwrap_or(l), "design.paragraphSpacing", json!({"value": v}));
continue;
}
mi(ui, app, l, "design.paragraphSpacing", json!({"value": v}));
}
});
@@ -3434,6 +3440,9 @@ mod tests {
/// in Polish the table tabs were out of reach. They stay on the strip and a click opens them.
#[test]
fn table_tabs_stay_clickable_when_the_tab_strip_overflows() {
if !crate::i18n::catalogs_embedded() {
return;
}
use egui_kittest::kittest::Queryable;
let mut app = WordApp::new(Session::new(wordcraft_doc::Document::from_text("Text")), crate::Services::default());
app.run("insert.table", json!({"rows": 3, "cols": 3})).unwrap();
@@ -3461,6 +3470,9 @@ mod tests {
/// Editing / Share, the tabs stay inside the window, and the buttons keep their room.
#[test]
fn tab_strip_never_overlaps_the_right_buttons() {
if !crate::i18n::catalogs_embedded() {
return;
}
use egui_kittest::kittest::Queryable;
for lang in ["en", "pl", "de"] {
for width in [300.0, 420.0, 520.0, 640.0, 765.0, 1280.0] {