1
0
forked from GRIN/grim
Files
goblin/tests/i18n_keys.rs
2ro f0b854171c Build 78: honest transport labels, request decline/cancel, NIP-05 on requests, full localization
Settings now says "Manual transaction" and the privacy row reads "Messages &
lookups" opening a new Network privacy page that tells the truth: messages,
names, price and avatars ride the Nym mixnet; the grin node connects directly.
README and lander updated to match.

Requests are messages, payments are final: declining a request now sends the
requester a void control message (NIP-17), a requester can cancel a request they
sent (cancels the local invoice and notifies the payer), and incoming requests
resolve the sender's verified @username instead of a bare npub. The Requested
amount on the success screen is centered. New NostrDecline/NostrCancel tasks and
a goblin-action control message carry it, bound to the stored counterparty.

Localization: every Goblin-screen string moved to t!() keys (370 keys) and
translated into de/fr/ru/tr/zh-CN, guarded by a key/placeholder drift test.
System-locale auto-detect now matches region locales like zh-CN.
2026-06-14 21:44:24 -04:00

126 lines
3.8 KiB
Rust

// Copyright 2026 The Goblin Developers
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//! Localization drift guard. en.yml is the source of truth: every `goblin.*`
//! key (and its `%{...}` interpolation placeholders) must exist, identically,
//! in every other locale, so the language picker never falls back to a raw key
//! or a string that drops a value. Fails CI the moment a translation lags.
use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;
/// The locales shipped alongside English.
const OTHER_LOCALES: &[&str] = &["de", "fr", "ru", "tr", "zh-CN"];
/// Flatten a YAML mapping into dotted leaf keys → string value.
fn flatten(value: &serde_yaml::Value, prefix: &str, out: &mut BTreeMap<String, String>) {
match value {
serde_yaml::Value::Mapping(map) => {
for (k, v) in map {
let key = k.as_str().unwrap_or_default();
let next = if prefix.is_empty() {
key.to_string()
} else {
format!("{prefix}.{key}")
};
flatten(v, &next, out);
}
}
other => {
let s = match other {
serde_yaml::Value::String(s) => s.clone(),
serde_yaml::Value::Bool(b) => b.to_string(),
serde_yaml::Value::Number(n) => n.to_string(),
_ => String::new(),
};
out.insert(prefix.to_string(), s);
}
}
}
/// Load a locale file flattened to `goblin.*` keys only.
fn load_goblin(locale: &str) -> BTreeMap<String, String> {
let path = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("locales")
.join(format!("{locale}.yml"));
let text = std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("cannot read {}: {e}", path.display()));
let doc: serde_yaml::Value =
serde_yaml::from_str(&text).unwrap_or_else(|e| panic!("invalid YAML in {locale}.yml: {e}"));
let mut all = BTreeMap::new();
flatten(&doc, "", &mut all);
all.into_iter()
.filter(|(k, _)| k.starts_with("goblin."))
.collect()
}
/// `%{name}` placeholders contained in a value, sorted.
fn placeholders(s: &str) -> BTreeSet<String> {
let mut out = BTreeSet::new();
let bytes = s.as_bytes();
let mut i = 0;
while i + 1 < bytes.len() {
if bytes[i] == b'%' && bytes[i + 1] == b'{' {
if let Some(end) = s[i..].find('}') {
out.insert(s[i..i + end + 1].to_string());
i += end + 1;
continue;
}
}
i += 1;
}
out
}
#[test]
fn every_locale_has_all_goblin_keys() {
let en = load_goblin("en");
assert!(
en.len() > 300,
"en.yml goblin block looks too small ({} keys) — did it load?",
en.len()
);
let en_keys: BTreeSet<&String> = en.keys().collect();
let mut problems = Vec::new();
for &loc in OTHER_LOCALES {
let other = load_goblin(loc);
let other_keys: BTreeSet<&String> = other.keys().collect();
for missing in en_keys.difference(&other_keys) {
problems.push(format!("{loc}: MISSING key {missing}"));
}
for extra in other_keys.difference(&en_keys) {
problems.push(format!("{loc}: EXTRA key {extra} (not in en.yml)"));
}
// Placeholder parity: a translation must carry the same %{...} args.
for (k, en_val) in &en {
if let Some(other_val) = other.get(k) {
if placeholders(en_val) != placeholders(other_val) {
problems.push(format!(
"{loc}: placeholder mismatch in {k} (en {:?} vs {:?})",
placeholders(en_val),
placeholders(other_val)
));
}
}
}
}
assert!(
problems.is_empty(),
"localization drift detected:\n{}",
problems.join("\n")
);
}