aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/test_locales.py
diff options
context:
space:
mode:
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?"]