Coverage for tests / test_l10n.py: 100.000%
149 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-07-06 21:34 +0200
« prev ^ index » next coverage.py v7.13.1, created at 2026-07-06 21:34 +0200
1# SPDX-FileCopyrightText: 2026 Marco Ricci <software@the13thletter.info>
2#
3# SPDX-License-Identifier: Zlib
5"""Test the localization machinery."""
7from __future__ import annotations
9import contextlib
10import errno
11import gettext
12import os
13import re
14import types
15from typing import TYPE_CHECKING, TypeVar
17import hypothesis
18import pytest
19from hypothesis import strategies
21from derivepassphrase._internals import cli_messages as msg
22from tests.machinery import hypothesis as hypothesis_machinery
24if TYPE_CHECKING:
25 from collections.abc import Generator
26 from typing import ClassVar
29class Parametrize(types.SimpleNamespace):
30 MAYBE_FORMAT_STRINGS = hypothesis_machinery.explicit_examples(
31 "s", ["{spam}", "{spam}abc", "{", "}", "{{{"]
32 )
35@pytest.fixture(scope="class")
36def use_debug_translations() -> Generator[None, None, None]:
37 """Force the use of debug translations (pytest class fixture)."""
38 with pytest.MonkeyPatch.context() as monkeypatch:
39 monkeypatch.setattr(msg, "translation", msg.DebugTranslations())
40 yield
43@contextlib.contextmanager
44def monkeypatched_null_translations() -> Generator[None, None, None]:
45 """Force the use of no-op translations in this context."""
46 with pytest.MonkeyPatch.context() as monkeypatch: 1fdbe
47 monkeypatch.setattr(msg, "translation", gettext.NullTranslations()) 1fdbe
48 yield 1fdbe
51class Strategies:
52 """`hypothesis` strategies for testing the localization machinery."""
54 all_translatable_strings_dict: ClassVar[
55 dict[
56 msg.TranslatableString,
57 msg.MsgTemplate,
58 ]
59 ] = {}
60 for enum_class in msg.MSG_TEMPLATE_CLASSES:
61 all_translatable_strings_dict.update({v.value: v for v in enum_class})
63 all_translatable_strings_enum_values = tuple(
64 sorted(all_translatable_strings_dict.values(), key=str)
65 )
66 all_translatable_strings = tuple(
67 v.value for v in all_translatable_strings_enum_values
68 )
70 error_codes = tuple(
71 sorted(errno.errorcode, key=errno.errorcode.__getitem__)
72 )
73 """A cache of the known error codes from the [`errno`][] module."""
74 known_fields_error_messages = tuple(
75 e
76 for e in sorted(msg.ErrMsgTemplate, key=str)
77 if e.value.fields() == ["error", "filename"]
78 )
79 """
80 A cache of known error messages that contain both `error` and
81 `filename` replacement fields.
82 """
83 no_fields_messages = tuple(
84 e for e in all_translatable_strings_enum_values if not e.value.fields()
85 )
86 """A cache of known messages that don't contain replacement fields."""
88 @classmethod
89 def errnos(cls) -> strategies.SearchStrategy[int]:
90 """Return a strategy for errno values."""
91 return strategies.sampled_from(cls.error_codes)
93 @classmethod
94 def error_messages_with_fields(
95 cls,
96 ) -> strategies.SearchStrategy[msg.ErrMsgTemplate]:
97 """Return a strategy for error messages with known replacement fields.
99 All error messages from this strategy contain (only) the "error"
100 and "filename" replacement fields.
102 """
103 return strategies.sampled_from(cls.known_fields_error_messages)
105 @staticmethod
106 def printable_strings() -> strategies.SearchStrategy[str]:
107 """Return a strategy for printable strings."""
108 return strategies.text(
109 strategies.characters(min_codepoint=32, max_codepoint=126),
110 min_size=1,
111 max_size=100,
112 )
114 @classmethod
115 def strings_without_fields(
116 cls,
117 ) -> strategies.SearchStrategy[msg.MsgTemplate]:
118 """Return a strategy for messages without any replacement fields."""
119 return strategies.sampled_from(cls.no_fields_messages)
121 @classmethod
122 def translatable_string_enums(
123 cls,
124 ) -> strategies.SearchStrategy[msg.MsgTemplate]:
125 """Return a strategy for translatable message enum values."""
126 return strategies.sampled_from(
127 cls.all_translatable_strings_enum_values
128 )
130 @classmethod
131 def translatable_strings(
132 cls,
133 ) -> strategies.SearchStrategy[msg.TranslatableString]:
134 """Return a strategy for translatable messages."""
135 return strategies.sampled_from(cls.all_translatable_strings)
137 T = TypeVar("T")
139 @staticmethod
140 def two_different(
141 strategy: strategies.SearchStrategy[T], /
142 ) -> strategies.SearchStrategy[tuple[T, T]]:
143 """Return a strategy for generating unique pairs of things.
145 We assume the "things" strategy to generate hashable elements.
147 Args:
148 strategy:
149 An existing search strategy.
151 """
152 return strategies.lists(
153 strategy, min_size=2, max_size=2, unique=True
154 ).map(tuple) # type: ignore[arg-type]
157@pytest.mark.usefixtures("use_debug_translations")
158class TestDebugTranslations:
159 """Test the localization machinery together with debug translations."""
161 @staticmethod
162 def _test_interpolated(
163 ts_name: str, ts_value: msg.TranslatableString
164 ) -> None:
165 context = ts_value.l10n_context 1gh
166 singular = ts_value.singular 1gh
167 translated = msg.translation.pgettext(context, singular) 1gh
168 assert translated.startswith(ts_name) 1gh
169 suffix = translated.removeprefix(ts_name) 1gh
170 assert not suffix or suffix.startswith("(") 1gh
172 @hypothesis.given(value=Strategies.printable_strings())
173 @hypothesis.example(value="{") 1aj
174 def test_get_str(self, value: str) -> None:
175 """Translating a raw string object does nothing."""
176 translated = msg.translation.gettext(value) 1j
177 assert translated == value 1j
179 @hypothesis.given(value=Strategies.printable_strings())
180 @hypothesis.example(value="{") 1ak
181 def test_get_ts_str(self, value: str) -> None:
182 """Translating a constant TranslatableString does nothing."""
183 translated = msg.TranslatedString.constant(value) 1k
184 assert str(translated) == value 1k
186 @hypothesis.given(value=Strategies.translatable_strings())
187 def test_get_ts( 1ah
188 self,
189 value: msg.TranslatableString,
190 ) -> None:
191 """Translating a TranslatableString translates and interpolates."""
192 self._test_interpolated( 1h
193 str(Strategies.all_translatable_strings_dict[value]), value
194 )
196 @hypothesis.given(value=Strategies.translatable_string_enums())
197 def test_get_enum( 1ag
198 self,
199 value: msg.MsgTemplate,
200 ) -> None:
201 """Translating a MsgTemplate operates on the enum value."""
202 self._test_interpolated(str(value), value.value) 1g
205@pytest.mark.usefixtures("use_debug_translations")
206class TestTranslatedStrings:
207 """Test the translated string objects."""
209 @hypothesis.given(
210 values=Strategies.two_different(Strategies.strings_without_fields())
211 )
212 def test_hashable(
213 self,
214 values: list[msg.MsgTemplate],
215 ) -> None:
216 """TranslatableStrings are hashable."""
217 assert len(values) == 2 1c
218 ts0 = msg.TranslatedString(values[0]) 1c
219 ts1 = msg.TranslatedString(values[0]) 1c
220 ts2 = msg.TranslatedString(values[1]) 1c
221 assert ts0 == ts1 1c
222 assert ts0 != ts2 1c
223 assert ts1 != ts2 1c
224 strings = {ts0} 1c
225 strings.add(ts1) 1c
226 assert len(strings) == 1 1c
227 strings.add(ts2) 1c
228 assert len(strings) == 2 1c
230 @hypothesis.given(
231 value=Strategies.error_messages_with_fields(),
232 errnos=Strategies.two_different(Strategies.errnos()),
233 )
234 def test_hashable_interpolated(
235 self,
236 value: msg.ErrMsgTemplate,
237 errnos: list[int],
238 ) -> None:
239 """TranslatableStrings are hashable even with interpolations."""
240 assert len(errnos) == 2 1i
241 error1, error2 = [os.strerror(c) for c in errnos] 1i
242 # The Annoying OS has error codes with identical strerror values.
243 hypothesis.assume(error1 != error2) 1i
244 ts1 = msg.TranslatedString( 1i
245 value, error=error1, filename=None
246 ).maybe_without_filename()
247 ts2 = msg.TranslatedString( 1i
248 value, error=error2, filename=None
249 ).maybe_without_filename()
250 assert str(ts1) != str(ts2) 1i
251 assert ts1 != ts2 1i
252 assert len({ts1, ts2}) == 2 1i
254 @hypothesis.given(
255 value=Strategies.error_messages_with_fields(),
256 errno_=Strategies.errnos(),
257 )
258 def test_hashable_interpolated_error_and_filename(
259 self,
260 value: msg.ErrMsgTemplate,
261 errno_: int,
262 ) -> None:
263 """Interpolated TranslatableStrings with error/filename are hashable."""
264 error = os.strerror(errno_) 1d
265 # The debug translations specifically do *not* differ in output
266 # when the filename is trimmed. So we need to request some
267 # other predictable, non-debug output.
268 #
269 # Also, because of the class-scoped fixture, and because
270 # hypothesis interferes with a function-scoped fixture, we also
271 # need to do our own manual monkeypatching here, separately, for
272 # each hypothesis iteration.
273 with monkeypatched_null_translations(): 1d
274 ts0 = msg.TranslatedString(value, error=error, filename=None) 1d
275 ts1 = ts0.maybe_without_filename() 1d
276 assert str(ts0) != str(ts1) 1d
277 assert ts0 != ts1 1d
278 assert len({ts0, ts1}) == 2 1d
281@pytest.mark.usefixtures("use_debug_translations")
282class TestSuppressedInterpolations:
283 """Test the interpolation suppression behavior of translated strings."""
285 # TODO(the-13th-letter): Revise this test, in particular, the error
286 # assertions.
287 @Parametrize.MAYBE_FORMAT_STRINGS
288 def test_missing_fields(self, s: str) -> None: 1ab
289 """TranslatableStrings require fixed replacement fields.
291 They reject attempts at stringification if unknown fields are
292 passed, or if fields are missing, or if the format string is
293 invalid.
295 """
296 with monkeypatched_null_translations(): 1b
297 ts1 = msg.TranslatedString(s) 1b
298 ts2 = msg.TranslatedString(s, spam="eggs") 1b
299 if "{spam}" in s: 1b
300 with pytest.raises(KeyError, match=r"spam"): 1b
301 str(ts1) 1b
302 assert str(ts2) == s.replace("{spam}", "eggs") 1b
303 else:
304 # Known error message variations:
305 #
306 # * Single { encountered in the pattern string
307 # * Single } encountered in the pattern string
308 # * Single '{' encountered in the pattern string
309 # * Single '}' encountered in the pattern string
310 # * Single '{'
311 # * Single '}'
312 pattern = re.compile( 1b
313 r"Single (?:\{|\}|'\{'|'\}')(?: encountered in the pattern string)?"
314 )
315 with pytest.raises(ValueError, match=pattern): 1b
316 str(ts1) 1b
317 with pytest.raises(ValueError, match=pattern): 1b
318 str(ts2) 1b
320 @hypothesis.given(s=Strategies.printable_strings())
321 def test_constant_strings(self, s: str) -> None: 1af
322 """Constant TranslatedStrings don't interpolate fields."""
323 with monkeypatched_null_translations(): 1f
324 ts = msg.TranslatedString.constant(s) 1f
325 try: 1f
326 assert str(ts) == s 1f
327 except ValueError as exc: # pragma: no cover
328 # Not a test error (= test author's fault), but
329 # a regression (= code under test is at fault).
330 err_msg = "Interpolation attempted"
331 raise AssertionError(err_msg) from exc
333 @hypothesis.given(s=Strategies.printable_strings())
334 def test_nonformat_strings(self, s: str) -> None: 1ae
335 """Non-format TranslatedStrings don't interpolate fields."""
336 with monkeypatched_null_translations(): 1e
337 ts_inner = msg.TranslatableString( 1e
338 "",
339 "{spam}" + s,
340 flags=frozenset({"no-python-brace-format"}),
341 )
342 ts = msg.TranslatedString(ts_inner, spam="eggs") 1e
343 try: 1e
344 assert str(ts) == "{spam}" + s 1e
345 except ValueError as exc: # pragma: no cover
346 # Not a test error (= test author's fault), but
347 # a regression (= code under test is at fault).
348 err_msg = "Interpolation attempted"
349 raise AssertionError(err_msg) from exc