summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/test_locales.py
blob: 59ea2bfb7c1a68f68df852c5d2afbac2d4a3afae (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
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']]) {
          Object.defineProperty(globalThis, 'navigator', { value: { languages: tags, language: tags[0] }, configurable: true });
          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: {} };
        Object.defineProperty(globalThis, 'navigator', { value: { languages: ['pl'], language: 'pl' }, configurable: true });
        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: {} };
        Object.defineProperty(globalThis, 'navigator', { value: { languages: ['en'], language: 'en' }, configurable: true });
        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?"]