""" 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?"]