aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/test_locales.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-16 00:21:07 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-16 00:21:07 +0200
commit3b2d03a919e56d14f8097844b97b844ddf526cd6 (patch)
tree31b222fedf6d608809eb47c45c5f431d7001d87a /packages/meshbay-hub/tests/test_locales.py
parent75065cc1ed5d9cd733871d478dc4f915663d795c (diff)
downloadmeshbay-3b2d03a919e56d14f8097844b97b844ddf526cd6.tar.gz
feat(hub): translate the web client into nine more languages
French, Spanish, Brazilian Portuguese, Simplified Chinese, Japanese, German, Italian, Dutch and Polish, all in the formal register. `hub`, `node` and `GEK` stay in English: they name the CLI, node.toml and the docs, and translating them would cut the interface off from everything an operator reads and types. Catalogues move out of i18n.js into locales/, one file per language, fetched with a dynamic import. A visitor downloads their language plus English as a fallback — about 36 KB rather than the ~180 KB that ten inlined catalogues would have cost everyone. i18n.js keeps only the loader, so the first render now waits for initLocale(). Three things the old code got wrong, none of them visible until there was a second language: - Resolution trimmed a tag to its base before matching, so a browser reporting pt-BR looked for a `pt` catalogue that does not exist and fell back to English. Matching is now exact first, then by base language. - Counted strings were single strings, so Polish could not express 1 plik / 2 pliki / 5 plików at all. t() selects through Intl.PluralRules; en.js gains the same treatment, which incidentally fixes "1 files". - Interpolation used String.replace, which reads `$&` in the replacement. A file named rap$&sody.mp3 rendered corrupted in its own delete dialog. Five strings were still hardcoded in app.js — the group name placeholder and the four visibility/join-policy descriptions — and are now keyed. test_locales.py holds the nine translations to the shape of en.js: same keys, a counted string stays counted everywhere, every plural entry covers each category Intl actually produces for that language, and the {placeholders} survive translation. Verified failing first, against a catalogue with a key removed, a placeholder dropped and the Polish `few` form deleted. The language menu also grew from one entry to ten, which overran a short viewport inside a dropdown that clipped instead of scrolling. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/tests/test_locales.py')
-rw-r--r--packages/meshbay-hub/tests/test_locales.py214
1 files changed, 214 insertions, 0 deletions
diff --git a/packages/meshbay-hub/tests/test_locales.py b/packages/meshbay-hub/tests/test_locales.py
new file mode 100644
index 0000000..4035f1f
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_locales.py
@@ -0,0 +1,214 @@
+"""
+The ten SPA catalogues, held to the shape of the English one.
+
+A missing key is not an error anywhere — `t()` falls back to English and the
+interface simply shows one line in the wrong language, which nobody reports.
+The same is true of a plural entry that lacks a category a language actually
+uses: it degrades to `other` and reads as broken grammar to a native speaker
+and as nothing at all to everyone else. Both are cheap to check and invisible
+to find by hand across ten files, so they are checked here.
+
+The placeholder assertion is the one with teeth: a translation that drops
+`{name}` renders a confirmation dialog naming nothing, and one that invents a
+placeholder renders a literal `{foo}`.
+"""
+
+import json
+import re
+import shutil
+import subprocess
+from pathlib import Path
+
+import pytest
+
+STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
+LOCALES = STATIC / "locales"
+I18N = STATIC / "i18n.js"
+
+pytestmark = pytest.mark.skipif(
+ shutil.which("node") is None or not LOCALES.exists(),
+ reason="node or the SPA sources are not available")
+
+# Kept in step with LOCALES in i18n.js — the registry the language menu offers.
+EXPECTED = ["en", "fr", "es", "pt-BR", "zh-CN", "ja", "de", "it", "nl", "pl"]
+
+
+def _sandbox(tmp_path):
+ """A directory node will treat as ESM, holding the real sources."""
+ (tmp_path / "package.json").write_text('{"type":"module"}')
+ (tmp_path / "locales").mkdir(exist_ok=True)
+ for src in LOCALES.glob("*.js"):
+ (tmp_path / "locales" / src.name).write_text(src.read_text())
+ (tmp_path / "i18n.js").write_text(I18N.read_text())
+ return tmp_path
+
+
+def _node(tmp_path, body):
+ script = tmp_path / "case.js"
+ script.write_text(body)
+ proc = subprocess.run(
+ ["node", str(script)], capture_output=True, text=True, cwd=str(tmp_path))
+ assert proc.returncode == 0, proc.stderr
+ return json.loads(proc.stdout)
+
+
+@pytest.fixture(scope="module")
+def catalogues(tmp_path_factory):
+ tmp = _sandbox(tmp_path_factory.mktemp("locales"))
+ codes = json.dumps(EXPECTED)
+ return _node(tmp, f"""
+ const out = {{}};
+ for (const code of {codes}) {{
+ out[code] = (await import(`./locales/${{code}}.js`)).default;
+ }}
+ console.log(JSON.stringify(out));
+ """)
+
+
+@pytest.fixture(scope="module")
+def plural_categories(tmp_path_factory):
+ """The categories Intl really produces per locale, not a table copied from CLDR."""
+ tmp = _sandbox(tmp_path_factory.mktemp("plurals"))
+ codes = json.dumps(EXPECTED)
+ return _node(tmp, f"""
+ const out = {{}};
+ for (const code of {codes}) {{
+ const pr = new Intl.PluralRules(code);
+ const cats = new Set();
+ for (let i = 0; i <= 200; i++) cats.add(pr.select(i));
+ cats.add(pr.select(1.5));
+ out[code] = [...cats];
+ }}
+ console.log(JSON.stringify(out));
+ """)
+
+
+def _placeholders(value):
+ """The `{name}` slots in an entry, plural forms flattened together."""
+ text = value if isinstance(value, str) else " ".join(value.values())
+ return set(re.findall(r"\{[a-z_]+\}", text))
+
+
+def test_every_registered_locale_has_a_catalogue():
+ on_disk = {p.stem for p in LOCALES.glob("*.js")}
+ assert on_disk == set(EXPECTED), (
+ "i18n.js offers a language in its menu that has no catalogue, or a "
+ "catalogue exists that the menu never offers")
+
+
+def test_key_sets_match_english(catalogues):
+ reference = set(catalogues["en"])
+ for code, catalogue in catalogues.items():
+ assert set(catalogue) == reference, (
+ f"{code} differs from en: "
+ f"missing {sorted(reference - set(catalogue))}, "
+ f"extra {sorted(set(catalogue) - reference)}")
+
+
+def test_plural_entries_stay_plural(catalogues):
+ for code, catalogue in catalogues.items():
+ for key, en_value in catalogues["en"].items():
+ if key not in catalogue:
+ continue # reported by test_key_sets_match_english
+ assert isinstance(catalogue[key], type(en_value)), (
+ f"{code}:{key} is a {type(catalogue[key]).__name__} where en has a "
+ f"{type(en_value).__name__} — a counted string must stay counted, "
+ "or t() cannot select a form")
+
+
+def test_plural_entries_cover_every_category_the_language_uses(
+ catalogues, plural_categories):
+ for code, catalogue in catalogues.items():
+ for key, value in catalogue.items():
+ if not isinstance(value, dict):
+ continue
+ missing = set(plural_categories[code]) - set(value)
+ assert not missing, (
+ f"{code}:{key} has no form for {sorted(missing)} — Polish needs "
+ "one/few/many, and a missing form silently falls back to 'other'")
+
+
+def test_placeholders_survive_translation(catalogues):
+ for code, catalogue in catalogues.items():
+ for key, en_value in catalogues["en"].items():
+ if key not in catalogue:
+ continue # reported by test_key_sets_match_english
+ assert _placeholders(catalogue[key]) == _placeholders(en_value), (
+ f"{code}:{key} interpolates "
+ f"{sorted(_placeholders(catalogue[key]))} where en interpolates "
+ f"{sorted(_placeholders(en_value))}")
+
+
+def test_locale_resolution_is_region_aware(tmp_path):
+ """`navigator.language` is pt-BR and zh-CN on the machines those files are for.
+
+ Trimming a tag to its base before matching — which this used to do — sent a
+ Brazilian browser looking for a `pt` catalogue that does not exist, and it
+ fell back to English.
+ """
+ tmp = _sandbox(tmp_path)
+ resolved = _node(tmp, """
+ const store = {};
+ globalThis.localStorage = {
+ getItem: k => (k in store ? store[k] : null),
+ setItem: (k, v) => { store[k] = v; },
+ };
+ globalThis.document = { documentElement: {} };
+ const i18n = await import('./i18n.js');
+ const out = {};
+ for (const tags of [['pt-BR'], ['pt'], ['zh-CN'], ['zh'], ['fr-CA'],
+ ['de-AT'], ['ru', 'it'], ['ko']]) {
+ globalThis.navigator = { languages: tags, language: tags[0] };
+ delete store.mb_lang;
+ out[tags.join(',')] = await i18n.initLocale();
+ }
+ console.log(JSON.stringify(out));
+ """)
+ assert resolved == {
+ "pt-BR": "pt-BR",
+ "pt": "pt-BR",
+ "zh-CN": "zh-CN",
+ "zh": "zh-CN",
+ "fr-CA": "fr",
+ "de-AT": "de",
+ "ru,it": "it", # first supported language in the list wins
+ "ko": "en", # nothing matches, English answers
+ }
+
+
+def test_counted_string_picks_the_right_polish_form(tmp_path):
+ """1 plik / 2 pliki / 5 plików — the case a two-form catalogue cannot express."""
+ tmp = _sandbox(tmp_path)
+ rendered = _node(tmp, """
+ const store = {};
+ globalThis.localStorage = {
+ getItem: k => (k in store ? store[k] : null),
+ setItem: (k, v) => { store[k] = v; },
+ };
+ globalThis.document = { documentElement: {} };
+ globalThis.navigator = { languages: ['pl'], language: 'pl' };
+ const i18n = await import('./i18n.js');
+ await i18n.initLocale();
+ console.log(JSON.stringify(
+ [0, 1, 2, 5, 22].map(n => i18n.t('status.files', { n }))));
+ """)
+ assert rendered == ["0 plików", "1 plik", "2 pliki", "5 plików", "22 pliki"]
+
+
+def test_interpolated_value_is_not_read_as_a_replacement_pattern(tmp_path):
+ """A filename may contain `$&`, which String.replace would expand."""
+ tmp = _sandbox(tmp_path)
+ rendered = _node(tmp, """
+ const store = {};
+ globalThis.localStorage = {
+ getItem: k => (k in store ? store[k] : null),
+ setItem: (k, v) => { store[k] = v; },
+ };
+ globalThis.document = { documentElement: {} };
+ globalThis.navigator = { languages: ['en'], language: 'en' };
+ const i18n = await import('./i18n.js');
+ await i18n.initLocale();
+ console.log(JSON.stringify(
+ [i18n.t('group.delete_confirm', { name: 'rap$&sody.mp3' })]));
+ """)
+ assert rendered == ["Delete rap$&sody.mp3?"]