Files
bal-electrum-plugin/tests/test_i18n.py
bitcoinafterlife 9c654bf2bf i18n phases 1+2: BAL translation layer and translatable texts
Phase 1: new bal/i18n.py, BAL's own gettext layer (domain "bal", catalogs
read with plugin.read_file()). _() asks Electrum's catalog first, then
BAL's, then returns the English source. The Qt plugin loads the catalog of
Electrum's GUI language at start-up; the CLI stays English.

Phase 2: every user-visible GUI text is now a whole, extractable sentence
(Ruff INT rules enabled). Class-level texts are marked with N_() and
translated when shown. Stored data stays language-neutral: the status
history is written in English and translated for display, the calendar
defaults follow the GUI language, and the history label and wallet labels
are never translated because BAL uses them to recognise its transactions.

No visible change apart from the double colon fixed in the will detail.
See CHANGELOG entries 58 and 59 and PLAN_I18N.md.
2026-09-26 21:54:50 +02:00

310 lines
11 KiB
Python

"""Tests for bal/i18n.py, BAL's translation layer (PLAN_I18N.md, phase 1).
The BAL catalogs are built in memory (see ``_mo_bytes``), so these tests need
no compiled .mo file and no Babel. Electrum's translator is replaced by a
small dictionary, so the tests do not depend on Electrum's own catalogs.
Run standalone (``python3 tests/test_i18n.py``) or with pytest.
"""
import array
import os
import struct
import sys
from contextlib import contextmanager
from types import SimpleNamespace
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from bal import i18n # noqa: E402
IT_MO = "locale/it_IT/LC_MESSAGES/bal.mo"
def _mo_bytes(messages):
"""Return a GNU .mo catalog (UTF-8) holding ``messages`` {msgid: msgstr}.
Same layout as CPython's Tools/i18n/msgfmt.py: header, the sorted msgid
and msgstr index tables, then the NUL-terminated strings.
"""
entries = {"": "Content-Type: text/plain; charset=UTF-8\n"}
entries.update(messages)
keys = sorted(entries)
ids = strs = b""
offsets = []
for key in keys:
msgid, msgstr = key.encode("utf-8"), entries[key].encode("utf-8")
offsets.append((len(ids), len(msgid), len(strs), len(msgstr)))
ids += msgid + b"\0"
strs += msgstr + b"\0"
keystart = 7 * 4 + 16 * len(keys)
valuestart = keystart + len(ids)
koffsets, voffsets = [], []
for o1, l1, o2, l2 in offsets:
koffsets += [l1, o1 + keystart]
voffsets += [l2, o2 + valuestart]
header = struct.pack(
"Iiiiiii", 0x950412DE, 0, len(keys), 7 * 4, 7 * 4 + len(keys) * 8, 0, 0
)
return header + array.array("i", koffsets + voffsets).tobytes() + ids + strs
class FakePlugin:
"""Stands in for Electrum's BasePlugin: serves read_file() from a dict."""
def __init__(self, files=None):
self.files = files or {}
self.requested = []
def read_file(self, filename):
self.requested.append(filename)
if filename not in self.files:
raise KeyError(filename) # what a missing entry in the zip raises
return self.files[filename]
@contextmanager
def _i18n_state(electrum=None, files=None, lang=None):
"""Fake Electrum's translations, load a fake BAL catalog, restore after.
Restoring matters: bal.i18n keeps module-level state, shared with every
other test of the same pytest run.
"""
saved = (i18n._electrum_gettext, i18n._catalog)
table = electrum or {}
i18n._electrum_gettext = lambda msg: table.get(msg, msg)
plugin = FakePlugin(files)
try:
i18n.set_language(plugin, lang)
yield plugin
finally:
i18n._electrum_gettext, i18n._catalog = saved
i18n._rejected.clear()
def test_empty_string_is_never_translated():
# For gettext "" is the key of the catalog header.
with _i18n_state(files={IT_MO: _mo_bytes({})}, lang="it_IT"):
assert i18n._catalog is not None
assert i18n._("") == ""
def test_electrum_translation_wins_over_bal_catalog():
catalog = _mo_bytes({"Cancel": "Cancella", "Heirs": "Eredi"})
with _i18n_state(
electrum={"Cancel": "Annulla"}, files={IT_MO: catalog}, lang="it_IT"
):
assert i18n._("Cancel") == "Annulla" # Electrum has it: Electrum wins
assert i18n._("Heirs") == "Eredi" # only BAL has it
def test_english_source_when_nobody_translates():
with _i18n_state(files={IT_MO: _mo_bytes({"Heirs": "Eredi"})}, lang="it_IT"):
assert i18n._("Not in any catalog") == "Not in any catalog"
def test_translation_with_different_fields_is_rejected():
catalog = _mo_bytes(
{
"{} heirs": "eredi", # field dropped: rejected
"Fee: {}": "Commissione: {}",
"{0} of {1}": "{1} di {0}", # re-ordered: allowed
"Hello {name}": "Ciao {nome}", # field renamed: rejected
}
)
with _i18n_state(files={IT_MO: catalog}, lang="it_IT"):
assert i18n._("{} heirs") == "{} heirs"
assert i18n._("Fee: {}") == "Commissione: {}"
assert i18n._("{0} of {1}") == "{1} di {0}"
assert i18n._("Hello {name}") == "Hello {name}"
def test_keeps_format_fields():
assert i18n.keeps_format_fields("Amount: {}", "Importo: {}")
assert not i18n.keeps_format_fields("Amount: {}", "Importo")
assert not i18n.keeps_format_fields("Amount: {}", "Importo: {} {}")
assert not i18n.keeps_format_fields("Amount: {}", "Importo: {") # malformed
assert i18n.keeps_format_fields("$wallet_name", "$wallet_name")
def test_english_loads_no_catalog():
with _i18n_state(files={IT_MO: _mo_bytes({"Heirs": "Eredi"})}, lang="en_UK") as p:
assert i18n._catalog is None
assert p.requested == []
assert i18n._("Heirs") == "Heirs"
def test_no_language_loads_no_catalog():
for lang in (None, ""):
with _i18n_state(files={IT_MO: _mo_bytes({"Heirs": "Eredi"})}, lang=lang):
assert i18n._catalog is None
def test_missing_catalog_falls_back_to_english():
with _i18n_state(files={}, lang="it_IT") as plugin:
assert i18n._catalog is None
assert plugin.requested == [IT_MO, "locale/it/LC_MESSAGES/bal.mo"]
assert i18n._("Heirs") == "Heirs"
def test_broken_catalog_falls_back_to_english():
with _i18n_state(files={IT_MO: b"this is not a catalog"}, lang="it_IT"):
assert i18n._catalog is None
assert i18n._("Heirs") == "Heirs"
def test_language_code_without_country_is_tried():
files = {"locale/it/LC_MESSAGES/bal.mo": _mo_bytes({"Heirs": "Eredi"})}
with _i18n_state(files=files, lang="it_IT"):
assert i18n._("Heirs") == "Eredi"
def test_n_marks_without_translating():
with _i18n_state(files={IT_MO: _mo_bytes({"Heirs": "Eredi"})}, lang="it_IT"):
assert i18n.N_("Heirs") == "Heirs"
assert i18n._(i18n.N_("Heirs")) == "Eredi"
def test_init_from_config_uses_electrum_language_setting():
config = SimpleNamespace(LOCALIZATION_LANGUAGE="it_IT")
plugin = FakePlugin({IT_MO: _mo_bytes({"Heirs": "Eredi"})})
with _i18n_state():
i18n.init_from_config(plugin, config)
assert i18n._("Heirs") == "Eredi"
def test_init_from_config_without_setting_uses_system_language():
import electrum.gui.default_lang as default_lang
saved = default_lang.get_default_language
default_lang.get_default_language = lambda gui_name=None: "it_IT"
config = SimpleNamespace(LOCALIZATION_LANGUAGE="")
plugin = FakePlugin({IT_MO: _mo_bytes({"Heirs": "Eredi"})})
try:
with _i18n_state():
i18n.init_from_config(plugin, config)
assert i18n._("Heirs") == "Eredi"
finally:
default_lang.get_default_language = saved
def test_init_from_config_never_raises():
plugin = FakePlugin({IT_MO: _mo_bytes({"Heirs": "Eredi"})})
with _i18n_state():
i18n.init_from_config(plugin, object()) # config without the setting
assert i18n._catalog is None
# --- Stored data stays language-neutral (PLAN_I18N.md, phase 2) -------------
def test_status_history_is_stored_in_english():
from bal.core.will import WillItem
catalog = _mo_bytes({"Signed": "Firmato", "Valid": "Valida"})
with _i18n_state(files={IT_MO: catalog}, lang="it_IT"):
# A bare item: set_status() only needs its status fields.
item = WillItem.__new__(WillItem)
item.status = ""
item.STATUS = WillItem.copy_status_table(WillItem.STATUS_DEFAULT)
item.set_status("COMPLETE", True)
item.set_status("VALID", False)
assert item.status == ".Signed.NOT Valid"
def test_status_history_is_translated_for_display():
from bal.core.will import format_status_history
catalog = _mo_bytes(
{
"Signed": "Firmato",
"Valid": "Valida",
"NOT {}": "NON {}",
"New": "Nuovo",
"Replaced": "Sostituita",
}
)
with _i18n_state(files={IT_MO: catalog}, lang="it_IT"):
assert format_status_history(".Signed.NOT Valid") == ".Firmato.NON Valida"
# A real history saved by an older version (tests/samanta7), which
# stored some labels already translated: known English labels are
# translated, the other tokens are kept as they are.
assert (
format_status_history("New.Firmato.Pushed.Checked.Replaced")
== "Nuovo.Firmato.Pushed.Checked.Sostituita"
)
assert format_status_history(".SignedERROR!!!") == ".FirmatoERROR!!!"
with _i18n_state(): # English GUI: shown exactly as stored
assert format_status_history("New.Firmato.Pushed") == "New.Firmato.Pushed"
class _FakeConfig(dict):
"""Minimal stand-in for Electrum's SimpleConfig (get / set_key)."""
def get(self, key, default=None):
return dict.get(self, key, default)
def set_key(self, key, value, save=True):
self[key] = value
def test_translatable_config_default_follows_language():
from bal.core.plugin_base import BalConfig
config = _FakeConfig()
summary = BalConfig(config, "summary", "Will of $wallet_name", translatable=True)
plain = BalConfig(config, "label", "BAL label")
catalog = _mo_bytes(
{"Will of $wallet_name": "Testamento di $wallet_name", "BAL label": "X"}
)
with _i18n_state(files={IT_MO: catalog}, lang="it_IT"):
assert summary.get() == "Testamento di $wallet_name" # nothing stored
assert summary.localized_default() == "Testamento di $wallet_name"
summary.set("Testamento di $wallet_name") # e.g. the reset button
assert config["summary"] == "Will of $wallet_name" # stored in English
assert summary.get() == "Testamento di $wallet_name"
summary.set("My own text") # the user's text is kept as it is
assert summary.get() == "My own text"
assert plain.get() == "BAL label" # not translatable: never translated
with _i18n_state():
summary.set("Will of $wallet_name")
assert summary.get() == "Will of $wallet_name"
def test_calendar_reminder_suffix_is_translatable():
from datetime import datetime, timedelta
from bal.core.reminders import build_ics_reminders
now = datetime(2026, 1, 1)
kwargs = dict(
locktime=now + timedelta(days=400), basic_mode=True, description="d",
summary="s", wallet_name="w", heirs_details="", version="0", now=now,
)
assert "s (reminder 1/" in build_ics_reminders(**kwargs) # CLI default
ics = build_ics_reminders(reminder_suffix="(promemoria {idx}/{total})", **kwargs)
assert "s (promemoria 1/" in ics
def test_qr_preset_labels_match_chunk_presets():
# The labels are marked for translation in common.py because
# qrtransfer.py must stay free of Electrum imports (Android copy).
from bal.core.qrtransfer import CHUNK_PRESETS
from bal.gui.qt.common import QR_PRESET_LABELS
assert tuple(label for label, _budget in CHUNK_PRESETS) == QR_PRESET_LABELS
def test_real_translator_is_electrums():
# Outside the fakes above, BAL asks Electrum's real translator first.
from electrum.i18n import _ as electrum_gettext
assert i18n._electrum_gettext is electrum_gettext
if __name__ == "__main__":
tests = [v for k, v in sorted(globals().items()) if k.startswith("test_")]
for test in tests:
test()
print(f"[OK] All {len(tests)} i18n tests passed")