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 |