Refactor the localization machinery tests
Marco Ricci

Marco Ricci commited on 2025-08-17 15:58:47
Zeige 1 geänderte Dateien mit 148 Einfügungen und 96 Löschungen.


(This is part 1 of a series of refactorings for the test suite.)

Collect all test data generation strategies in a common class
`Strategies` (similar to the `Parametrize` class).  Split the tests into
groups for debug translations, operations on translatable strings
(currently only the hashability check) and suppression of interpolation.
Within each group, attempt to factor out common operations, though at
the moment, only the debug translations group has code factored out.
... ...
@@ -11,9 +11,8 @@ import errno
11 11
 import gettext
12 12
 import os
13 13
 import re
14
-import string
15 14
 import types
16
-from typing import TYPE_CHECKING, cast
15
+from typing import TYPE_CHECKING, TypeVar, cast
17 16
 
18 17
 import hypothesis
19 18
 import pytest
... ...
@@ -23,6 +22,7 @@ from derivepassphrase._internals import cli_messages as msg
23 22
 
24 23
 if TYPE_CHECKING:
25 24
     from collections.abc import Iterator
25
+    from typing import ClassVar
26 26
 
27 27
 
28 28
 class Parametrize(types.SimpleNamespace):
... ...
@@ -31,24 +31,6 @@ class Parametrize(types.SimpleNamespace):
31 31
     )
32 32
 
33 33
 
34
-all_translatable_strings_dict: dict[
35
-    msg.TranslatableString,
36
-    msg.MsgTemplate,
37
-] = {}
38
-for enum_class in msg.MSG_TEMPLATE_CLASSES:
39
-    all_translatable_strings_dict.update({
40
-        cast("msg.TranslatableString", v.value): v for v in enum_class
41
-    })
42
-
43
-all_translatable_strings_enum_values = tuple(
44
-    sorted(all_translatable_strings_dict.values(), key=str)
45
-)
46
-all_translatable_strings = [
47
-    cast("msg.TranslatableString", v.value)
48
-    for v in all_translatable_strings_enum_values
49
-]
50
-
51
-
52 34
 @pytest.fixture(scope="class")
53 35
 def use_debug_translations() -> Iterator[None]:
54 36
     """Force the use of debug translations (pytest class fixture)."""
... ...
@@ -65,9 +47,27 @@ def monkeypatched_null_translations() -> Iterator[None]:
65 47
         yield
66 48
 
67 49
 
68
-@pytest.mark.usefixtures("use_debug_translations")
69
-class TestL10nMachineryWithDebugTranslations:
70
-    """Test the localization machinery together with debug translations."""
50
+class Strategies:
51
+    """`hypothesis` strategies for testing the localization machinery."""
52
+
53
+    all_translatable_strings_dict: ClassVar[
54
+        dict[
55
+            msg.TranslatableString,
56
+            msg.MsgTemplate,
57
+        ]
58
+    ] = {}
59
+    for enum_class in msg.MSG_TEMPLATE_CLASSES:
60
+        all_translatable_strings_dict.update({
61
+            cast("msg.TranslatableString", v.value): v for v in enum_class
62
+        })
63
+
64
+    all_translatable_strings_enum_values = tuple(
65
+        sorted(all_translatable_strings_dict.values(), key=str)
66
+    )
67
+    all_translatable_strings = tuple(
68
+        cast("msg.TranslatableString", v.value)
69
+        for v in all_translatable_strings_enum_values
70
+    )
71 71
 
72 72
     error_codes = tuple(
73 73
         sorted(errno.errorcode, key=errno.errorcode.__getitem__)
... ...
@@ -87,60 +87,133 @@ class TestL10nMachineryWithDebugTranslations:
87 87
     )
88 88
     """A cache of known messages that don't contain replacement fields."""
89 89
 
90
-    @hypothesis.given(value=strategies.text(max_size=100))
90
+    @classmethod
91
+    def errnos(cls) -> strategies.SearchStrategy[int]:
92
+        """Return a strategy for errno values."""
93
+        return strategies.sampled_from(cls.error_codes)
94
+
95
+    @classmethod
96
+    def error_messages_with_fields(
97
+        cls,
98
+    ) -> strategies.SearchStrategy[msg.ErrMsgTemplate]:
99
+        """Return a strategy for error messages with known replacement fields.
100
+
101
+        All error messages from this strategy contain (only) the "error"
102
+        and "filename" replacement fields.
103
+
104
+        """
105
+        return strategies.sampled_from(cls.known_fields_error_messages)
106
+
107
+    @staticmethod
108
+    def printable_strings() -> strategies.SearchStrategy[str]:
109
+        """Return a strategy for printable strings."""
110
+        return strategies.text(
111
+            strategies.characters(min_codepoint=32, max_codepoint=126),
112
+            min_size=1,
113
+            max_size=100,
114
+        )
115
+
116
+    @classmethod
117
+    def strings_without_fields(
118
+        cls,
119
+    ) -> strategies.SearchStrategy[msg.MsgTemplate]:
120
+        """Return a strategy for messages without any replacement fields."""
121
+        return strategies.sampled_from(cls.no_fields_messages)
122
+
123
+    @classmethod
124
+    def translatable_string_enums(
125
+        cls,
126
+    ) -> strategies.SearchStrategy[msg.MsgTemplate]:
127
+        """Return a strategy for translatable message enum values."""
128
+        return strategies.sampled_from(
129
+            cls.all_translatable_strings_enum_values
130
+        )
131
+
132
+    @classmethod
133
+    def translatable_strings(
134
+        cls,
135
+    ) -> strategies.SearchStrategy[msg.MsgTemplate]:
136
+        """Return a strategy for translatable messages."""
137
+        return strategies.sampled_from(cls.all_translatable_strings)
138
+
139
+    T = TypeVar("T")
140
+
141
+    @staticmethod
142
+    def two_different(
143
+        strategy: strategies.SearchStrategy[T], /
144
+    ) -> strategies.SearchStrategy[tuple[T, T]]:
145
+        """Return a strategy for generating unique pairs of things.
146
+
147
+        We assume the "things" strategy to generate hashable elements.
148
+
149
+        Args:
150
+            strategy:
151
+                An existing search strategy.
152
+
153
+        """
154
+        return strategies.lists(
155
+            strategy, min_size=2, max_size=2, unique=True
156
+        ).map(tuple)  # type: ignore[arg-type]
157
+
158
+
159
+@pytest.mark.usefixtures("use_debug_translations")
160
+class TestDebugTranslations:
161
+    """Test the localization machinery together with debug translations."""
162
+
163
+    @staticmethod
164
+    def _test_interpolated(
165
+        ts_name: str, ts_value: msg.TranslatableString
166
+    ) -> None:
167
+        context = ts_value.l10n_context
168
+        singular = ts_value.singular
169
+        translated = msg.translation.pgettext(context, singular)
170
+        assert translated.startswith(ts_name)
171
+        suffix = translated.removeprefix(ts_name)
172
+        assert not suffix or suffix.startswith("(")
173
+
174
+    @hypothesis.given(value=Strategies.printable_strings())
91 175
     @hypothesis.example("{")
92
-    def test_debug_translation_get_str(self, value: str) -> None:
176
+    def test_get_str(self, value: str) -> None:
93 177
         """Translating a raw string object does nothing."""
94 178
         translated = msg.translation.gettext(value)
95 179
         assert translated == value
96 180
 
97
-    @hypothesis.given(value=strategies.sampled_from(all_translatable_strings))
98
-    def test_debug_translation_get_ts(
181
+    @hypothesis.given(value=Strategies.printable_strings())
182
+    @hypothesis.example("{")
183
+    def test_get_ts_str(self, value: str) -> None:
184
+        """Translating a constant TranslatableString does nothing."""
185
+        translated = msg.TranslatedString.constant(value)
186
+        assert str(translated) == value
187
+
188
+    @hypothesis.given(value=Strategies.translatable_strings())
189
+    def test_get_ts(
99 190
         self,
100 191
         value: msg.TranslatableString,
101 192
     ) -> None:
102 193
         """Translating a TranslatableString translates and interpolates."""
103
-        ts_name = str(all_translatable_strings_dict[value])
104
-        context = value.l10n_context
105
-        singular = value.singular
106
-        translated = msg.translation.pgettext(context, singular)
107
-        assert translated.startswith(ts_name)
108
-        suffix = translated.removeprefix(ts_name)
109
-        assert not suffix or suffix.startswith("(")
110
-
111
-    @hypothesis.given(
112
-        value=strategies.sampled_from(all_translatable_strings_enum_values)
194
+        self._test_interpolated(
195
+            str(Strategies.all_translatable_strings_dict[value]), value
113 196
         )
114
-    def test_debug_translation_get_enum(
197
+
198
+    @hypothesis.given(value=Strategies.translatable_string_enums())
199
+    def test_get_enum(
115 200
         self,
116 201
         value: msg.MsgTemplate,
117 202
     ) -> None:
118 203
         """Translating a MsgTemplate operates on the enum value."""
119
-        ts_name = str(value)
120
-        inner_value = cast("msg.TranslatableString", value.value)
121
-        context = inner_value.l10n_context
122
-        singular = inner_value.singular
123
-        translated = msg.translation.pgettext(context, singular)
124
-        assert translated.startswith(ts_name)
125
-        suffix = translated.removeprefix(ts_name)
126
-        assert not suffix or suffix.startswith("(")
204
+        self._test_interpolated(
205
+            str(value), cast("msg.TranslatableString", value.value)
206
+        )
127 207
 
128
-    @hypothesis.given(value=strategies.text(max_size=100))
129
-    @hypothesis.example("{")
130
-    def test_debug_translation_get_ts_str(self, value: str) -> None:
131
-        """Translating a constant TranslatableString does nothing."""
132
-        translated = msg.TranslatedString.constant(value)
133
-        assert str(translated) == value
208
+
209
+@pytest.mark.usefixtures("use_debug_translations")
210
+class TestTranslatedStrings:
211
+    """Test the translated string objects."""
134 212
 
135 213
     @hypothesis.given(
136
-        values=strategies.lists(
137
-            strategies.sampled_from(no_fields_messages),
138
-            min_size=2,
139
-            max_size=2,
140
-            unique=True,
214
+        values=Strategies.two_different(Strategies.strings_without_fields())
141 215
     )
142
-    )
143
-    def test_translated_strings_operations(
216
+    def test_hashable(
144 217
         self,
145 218
         values: list[msg.MsgTemplate],
146 219
     ) -> None:
... ...
@@ -159,15 +232,10 @@ class TestL10nMachineryWithDebugTranslations:
159 232
         assert len(strings) == 2
160 233
 
161 234
     @hypothesis.given(
162
-        value=strategies.sampled_from(known_fields_error_messages),
163
-        errnos=strategies.lists(
164
-            strategies.sampled_from(error_codes),
165
-            min_size=2,
166
-            max_size=2,
167
-            unique=True,
168
-        ),
235
+        value=Strategies.error_messages_with_fields(),
236
+        errnos=Strategies.two_different(Strategies.errnos()),
169 237
     )
170
-    def test_translated_strings_operations_interpolated(
238
+    def test_hashable_interpolated(
171 239
         self,
172 240
         value: msg.ErrMsgTemplate,
173 241
         errnos: list[int],
... ...
@@ -188,10 +256,10 @@ class TestL10nMachineryWithDebugTranslations:
188 256
         assert len({ts1, ts2}) == 2
189 257
 
190 258
     @hypothesis.given(
191
-        value=strategies.sampled_from(known_fields_error_messages),
192
-        errno_=strategies.sampled_from(error_codes),
259
+        value=Strategies.error_messages_with_fields(),
260
+        errno_=Strategies.errnos(),
193 261
     )
194
-    def test_translated_strings_operations_interpolated_error_and_filename(
262
+    def test_hashable_interpolated_error_and_filename(
195 263
         self,
196 264
         value: msg.ErrMsgTemplate,
197 265
         errno_: int,
... ...
@@ -213,11 +281,13 @@ class TestL10nMachineryWithDebugTranslations:
213 281
             assert ts0 != ts1
214 282
             assert len({ts0, ts1}) == 2
215 283
 
284
+
285
+@pytest.mark.usefixtures("use_debug_translations")
286
+class TestSuppressedInterpolations:
287
+    """Test the interpolation suppression behavior of translated strings."""
288
+
216 289
     @Parametrize.MAYBE_FORMAT_STRINGS
217
-    def test_translated_strings_suppressed_interpolation_fail(
218
-        self,
219
-        s: str,
220
-    ) -> None:
290
+    def test_missing_fields(self, s: str) -> None:
221 291
         """TranslatableStrings require fixed replacement fields.
222 292
 
223 293
         They reject attempts at stringification if unknown fields are
... ...
@@ -249,17 +319,8 @@ class TestL10nMachineryWithDebugTranslations:
249 319
                 with pytest.raises(ValueError, match=pattern):
250 320
                     str(ts2)
251 321
 
252
-    @hypothesis.given(
253
-        s=strategies.text(
254
-            strategies.sampled_from(string.ascii_lowercase + "{}"),
255
-            min_size=1,
256
-            max_size=20,
257
-        )
258
-    )
259
-    def test_translated_strings_suppressed_interpolation_str(
260
-        self,
261
-        s: str,
262
-    ) -> None:
322
+    @hypothesis.given(s=Strategies.printable_strings())
323
+    def test_constant_strings(self, s: str) -> None:
263 324
         """Constant TranslatedStrings don't interpolate fields."""
264 325
         with monkeypatched_null_translations():
265 326
             ts = msg.TranslatedString.constant(s)
... ...
@@ -271,17 +332,8 @@ class TestL10nMachineryWithDebugTranslations:
271 332
                 err_msg = "Interpolation attempted"
272 333
                 raise AssertionError(err_msg) from exc
273 334
 
274
-    @hypothesis.given(
275
-        s=strategies.text(
276
-            strategies.sampled_from(string.ascii_lowercase + "{}"),
277
-            min_size=1,
278
-            max_size=20,
279
-        )
280
-    )
281
-    def test_translated_strings_suppressed_interpolation_ts_manual(
282
-        self,
283
-        s: str,
284
-    ) -> None:
335
+    @hypothesis.given(s=Strategies.printable_strings())
336
+    def test_nonformat_strings(self, s: str) -> None:
285 337
         """Non-format TranslatedStrings don't interpolate fields."""
286 338
         with monkeypatched_null_translations():
287 339
             ts_inner = msg.TranslatableString(
288 340