diff options
Diffstat (limited to 'packages/meshbay-hub/tests/test_locales.py')
| -rw-r--r-- | packages/meshbay-hub/tests/test_locales.py | 214 |
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?"] |