Files
meshcore-bot/tests/test_i18n.py
agessaman 698f18dc6e fix(i18n): anchor the local catalog on local_dir_path and wire it everywhere
The local translation path defaulted to a bare `local/translations/`, resolved
against the process cwd and unrelated to `[Bot] local_dir_path`. Since
`local_dir_path` already selects where an operator's commands, service plugins
and config overlay live, the catalog belongs in that same tree. It now defaults
to `<local_dir_path>/translations`, resolved absolute against the bot root, so
relocating `local_dir_path` moves the catalog with it and the lookup no longer
depends on the working directory. An explicit `local_translation_path` still
wins.

Only the constructor at startup was passing the local path. `get_translator()`
builds and caches a translator per detected language, and `reload_config()`
builds a fresh one, and both were still constructing `Translator` with the
distributed path alone. So local overrides silently vanished from any reply that
used the sender's language, and did not survive a config reload. Both now pass
it, `reload_config()` republishes it alongside `translation_path`, and it is
snapshotted and restored on rollback. The DummyTranslator fallback sets it too,
since `get_translator()` would otherwise raise AttributeError there.

Collapsed `_deep_merge_translations` into the existing `_merge_translations`,
which already merged deeply with the primary winning. Rewrote that one to stop
mutating its input: it shallow-copied `fallback` and then recursed into the
shared sub-dicts, so merging a base language over English corrupted the cached
English catalog at every level below the first. Also replaced the two `print()`
calls on the error paths with logger calls.

Registered `local_translation_path` in the config schema next to
`translation_path`, and reverted a trailing-whitespace reflow that touched 84
lines of config.ini.example for a one-key addition.

Tests: local overlay (single-string override, added keys, local-only catalog,
missing directory), the merge no longer mutating its fallback, the default
following `local_dir_path`, an explicit setting overriding it, the overlay
reaching `bot.translator` end to end, and a reload picking up a moved path.
2026-09-07 20:28:45 -07:00

255 lines
11 KiB
Python

"""Tests for modules.i18n — Translator class."""
import json
from unittest.mock import patch
from modules.i18n import Translator
class TestExtractBaseLanguage:
"""Tests for _extract_base_language."""
def test_simple_code_unchanged(self):
t = Translator.__new__(Translator)
assert t._extract_base_language("en") == "en"
def test_hyphen_locale(self):
t = Translator.__new__(Translator)
assert t._extract_base_language("es-MX") == "es"
def test_underscore_locale(self):
t = Translator.__new__(Translator)
assert t._extract_base_language("es_ES") == "es"
def test_french(self):
t = Translator.__new__(Translator)
assert t._extract_base_language("fr") == "fr"
class TestMergeTranslations:
"""Tests for _merge_translations."""
def setup_method(self):
self.t = Translator.__new__(Translator)
def test_empty_primary_returns_copy_of_fallback(self):
fallback = {"a": "A", "b": "B"}
result = self.t._merge_translations({}, fallback)
assert result == fallback
assert result is not fallback # should be a copy
def test_primary_overrides_fallback(self):
primary = {"a": "OVERRIDE"}
fallback = {"a": "original", "b": "keep_me"}
result = self.t._merge_translations(primary, fallback)
assert result["a"] == "OVERRIDE"
assert result["b"] == "keep_me"
def test_nested_dicts_merged_recursively(self):
primary = {"grp": {"x": "X_override"}}
fallback = {"grp": {"x": "X_orig", "y": "Y_orig"}}
result = self.t._merge_translations(primary, fallback)
assert result["grp"]["x"] == "X_override"
assert result["grp"]["y"] == "Y_orig"
def test_flat_override_wins_over_nested_fallback(self):
primary = {"grp": "flat_string"}
fallback = {"grp": {"x": "nested"}}
result = self.t._merge_translations(primary, fallback)
assert result["grp"] == "flat_string"
class TestTranslatorWithRealFiles:
"""Tests that use actual translation files (if available)."""
def test_english_fallback_returns_key_when_missing(self, tmp_path):
en = {"commands": {"ping": {"response": "Pong!"}}}
(tmp_path / "en.json").write_text(json.dumps(en))
t = Translator(language="en", translation_path=str(tmp_path))
assert t.translate("commands.ping.response") == "Pong!"
def test_missing_key_returns_key(self, tmp_path):
en = {"hello": "world"}
(tmp_path / "en.json").write_text(json.dumps(en))
t = Translator(language="en", translation_path=str(tmp_path))
assert t.translate("commands.nonexistent.key") == "commands.nonexistent.key"
def test_translate_with_format_kwargs(self, tmp_path):
en = {"greeting": "Hello, {name}!"}
(tmp_path / "en.json").write_text(json.dumps(en))
t = Translator(language="en", translation_path=str(tmp_path))
assert t.translate("greeting", name="World") == "Hello, World!"
def test_format_failure_returns_unformatted(self, tmp_path):
en = {"greeting": "Hello, {name}!"}
(tmp_path / "en.json").write_text(json.dumps(en))
t = Translator(language="en", translation_path=str(tmp_path))
# Missing kwarg -> formatting fails -> return unformatted
result = t.translate("greeting")
# No kwargs: should just return the value without formatting
assert "Hello" in result or result == "Hello, {name}!"
def test_fallback_to_english_when_key_missing_in_locale(self, tmp_path):
en = {"only_in_english": "English value"}
es = {"other_key": "Spanish value"}
(tmp_path / "en.json").write_text(json.dumps(en))
(tmp_path / "es.json").write_text(json.dumps(es))
t = Translator(language="es", translation_path=str(tmp_path))
assert t.translate("only_in_english") == "English value"
def test_locale_overrides_base(self, tmp_path):
en = {"greeting": "Hello"}
es = {"greeting": "Hola"}
es_mx = {"greeting": "Que tal"}
(tmp_path / "en.json").write_text(json.dumps(en))
(tmp_path / "es.json").write_text(json.dumps(es))
(tmp_path / "es-MX.json").write_text(json.dumps(es_mx))
t = Translator(language="es-MX", translation_path=str(tmp_path))
assert t.translate("greeting") == "Que tal"
def test_get_available_languages(self, tmp_path):
for lang in ["en", "es", "fr"]:
(tmp_path / f"{lang}.json").write_text("{}")
t = Translator(language="en", translation_path=str(tmp_path))
langs = t.get_available_languages()
assert sorted(langs) == ["en", "es", "fr"]
def test_get_available_languages_missing_dir(self, tmp_path):
t = Translator(language="en", translation_path=str(tmp_path / "nonexistent"))
assert t.get_available_languages() == []
def test_default_catalog_loads_outside_source_tree(self, tmp_path, monkeypatch):
"""Installed wheels must not depend on the process working directory."""
monkeypatch.chdir(tmp_path)
t = Translator(language="en")
assert t.fallback_translations
assert "en" in t.get_available_languages()
def test_get_value_returns_raw_value(self, tmp_path):
en = {"commands": {"list": ["a", "b", "c"]}}
(tmp_path / "en.json").write_text(json.dumps(en))
t = Translator(language="en", translation_path=str(tmp_path))
val = t.get_value("commands.list")
assert val == ["a", "b", "c"]
def test_get_value_missing_key_returns_none(self, tmp_path):
en = {"key": "value"}
(tmp_path / "en.json").write_text(json.dumps(en))
t = Translator(language="en", translation_path=str(tmp_path))
assert t.get_value("no.such.key") is None
def test_reload_picks_up_new_content(self, tmp_path):
en_file = tmp_path / "en.json"
en_file.write_text(json.dumps({"msg": "original"}))
t = Translator(language="en", translation_path=str(tmp_path))
assert t.translate("msg") == "original"
en_file.write_text(json.dumps({"msg": "updated"}))
t.reload()
assert t.translate("msg") == "updated"
def test_invalid_json_returns_empty(self, tmp_path):
en_file = tmp_path / "en.json"
en_file.write_text("{invalid json")
t = Translator(language="en", translation_path=str(tmp_path))
# Should not crash; translations will be empty
result = t.translate("any.key")
assert result == "any.key"
def test_translate_non_string_value_returns_key(self, tmp_path):
en = {"items": ["a", "b"]}
(tmp_path / "en.json").write_text(json.dumps(en))
t = Translator(language="en", translation_path=str(tmp_path))
# List value should return key, not the list
assert t.translate("items") == "items"
def test_load_file_non_json_exception(self, tmp_path):
"""A non-JSONDecodeError exception in _load_file returns empty dict."""
en_file = tmp_path / "en.json"
en_file.write_text('{"key": "value"}')
t = Translator(language="en", translation_path=str(tmp_path))
# Now simulate generic exception by patching open
with patch("builtins.open", side_effect=PermissionError("denied")):
result = t._load_file("en")
assert result == {}
def test_translate_fallback_inner_loop_executes(self, tmp_path):
"""When outer loop misses, inner fallback loop finds nested key (line 148)."""
# English has nested key; target language has different top-level key
en = {"grp": {"deep": "found"}}
xx = {"other": "x"} # no 'grp' key
(tmp_path / "en.json").write_text(json.dumps(en))
(tmp_path / "xx.json").write_text(json.dumps(xx))
t = Translator(language="xx", translation_path=str(tmp_path))
# 'grp.deep' will miss in xx translations (outer loop fails on 'grp'),
# then inner fallback loop finds 'grp' in English (line 148), then 'deep'
result = t.translate("grp.deep")
assert result == "found"
def test_translate_format_failure_returns_unformatted(self, tmp_path):
"""When .format(**kwargs) raises, return the unformatted value (lines 158-160)."""
en = {"greeting": "Hello, {name}!"}
(tmp_path / "en.json").write_text(json.dumps(en))
t = Translator(language="en", translation_path=str(tmp_path))
# Pass a wrong kwarg to trigger KeyError in format
result = t.translate("greeting", wrong_key="x")
# Should return unformatted string, not crash
assert result == "Hello, {name}!"
def test_get_value_fallback_break(self, tmp_path):
"""get_value fallback inner loop completes and hits break (line 210)."""
en = {"grp": {"val": "found"}}
xx = {"other": "x"}
(tmp_path / "en.json").write_text(json.dumps(en))
(tmp_path / "xx.json").write_text(json.dumps(xx))
t = Translator(language="xx", translation_path=str(tmp_path))
result = t.get_value("grp.val")
assert result == "found"
class TestLocalCatalogOverlay:
"""The local catalog overlays the distributed one, key by key."""
def _write(self, path, data):
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(data), encoding="utf-8")
def test_local_overrides_single_string(self, tmp_path):
dist, local = tmp_path / "dist", tmp_path / "local"
self._write(dist / "en.json", {"commands": {"a": "shipped", "b": "keep"}})
self._write(local / "en.json", {"commands": {"a": "overridden"}})
t = Translator("en", str(dist), str(local))
assert t.translate("commands.a") == "overridden"
# Sibling keys the local file does not mention survive.
assert t.translate("commands.b") == "keep"
def test_local_adds_keys_for_local_commands(self, tmp_path):
dist, local = tmp_path / "dist", tmp_path / "local"
self._write(dist / "en.json", {"commands": {"ping": "pong"}})
self._write(local / "en.json", {"commands": {"whois": "Looking up {call}"}})
t = Translator("en", str(dist), str(local))
assert t.translate("commands.whois", call="K7ABC") == "Looking up K7ABC"
assert t.translate("commands.ping") == "pong"
def test_local_only_catalog_is_used(self, tmp_path):
dist, local = tmp_path / "dist", tmp_path / "local"
dist.mkdir()
self._write(local / "en.json", {"commands": {"whois": "local only"}})
t = Translator("en", str(dist), str(local))
assert t.translate("commands.whois") == "local only"
def test_missing_local_dir_is_harmless(self, tmp_path):
dist = tmp_path / "dist"
self._write(dist / "en.json", {"commands": {"ping": "pong"}})
t = Translator("en", str(dist), str(tmp_path / "nope"))
assert t.translate("commands.ping") == "pong"
def test_merge_does_not_mutate_fallback(self):
"""_merge_translations must not write through into its fallback's sub-dicts."""
t = Translator.__new__(Translator)
fallback = {"grp": {"x": "orig", "y": "keep"}}
result = t._merge_translations({"grp": {"x": "new"}}, fallback)
assert result["grp"]["x"] == "new"
# The caller's catalog is left exactly as it was.
assert fallback == {"grp": {"x": "orig", "y": "keep"}}
assert result["grp"] is not fallback["grp"]