diff options
| author | natsuoto <[email protected]> | 2026-07-15 15:53:53 +0530 |
|---|---|---|
| committer | natsuoto <[email protected]> | 2026-07-15 15:53:53 +0530 |
| commit | 7bb864a7c55d426e28a94d1ce792b34e169f71d8 (patch) | |
| tree | 83408a9aadbcc7d2625a138ee21134d12c4080f4 /tests | |
| parent | 33e6382dba3603f4d4a1dafeca667caae2983c9c (diff) | |
| download | edify-7bb864a7c55d426e28a94d1ce792b34e169f71d8.tar.xz edify-7bb864a7c55d426e28a94d1ce792b34e169f71d8.zip | |
test(property): Hypothesis harness + property tests for builder emission, roundtrips, quantifier count parity, reverse parser
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/conftest.py | 43 | ||||
| -rw-r--r-- | tests/property/emitted.test.py | 50 | ||||
| -rw-r--r-- | tests/property/parsing.test.py | 76 | ||||
| -rw-r--r-- | tests/property/quantifiers.test.py | 59 | ||||
| -rw-r--r-- | tests/property/roundtrip.test.py | 63 |
5 files changed, 291 insertions, 0 deletions
diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..16a5914 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,43 @@ +"""Shared pytest fixtures + Hypothesis profile registration. + +Registers three Hypothesis profiles chosen at collection time via the +``EDIFY_HYPOTHESIS_PROFILE`` environment variable: + +* ``dev`` (default) — fast, 50 examples per property. +* ``ci`` — 300 examples, deterministic seed via HYPOTHESIS_DATABASE_FILE. +* ``nightly`` — 2000 examples with the shrinker granted extra time. +""" + +from __future__ import annotations + +import os + +from hypothesis import HealthCheck, Verbosity, settings + +_PROFILE_ENVIRONMENT_VARIABLE = "EDIFY_HYPOTHESIS_PROFILE" + + +settings.register_profile( + "dev", + max_examples=50, + deadline=None, + verbosity=Verbosity.normal, + suppress_health_check=[HealthCheck.too_slow], +) +settings.register_profile( + "ci", + max_examples=300, + deadline=None, + verbosity=Verbosity.normal, + suppress_health_check=[HealthCheck.too_slow], +) +settings.register_profile( + "nightly", + max_examples=2000, + deadline=None, + verbosity=Verbosity.verbose, + suppress_health_check=[HealthCheck.too_slow], +) + +_chosen_profile = os.environ.get(_PROFILE_ENVIRONMENT_VARIABLE, "dev") +settings.load_profile(_chosen_profile) diff --git a/tests/property/emitted.test.py b/tests/property/emitted.test.py new file mode 100644 index 0000000..6c66e18 --- /dev/null +++ b/tests/property/emitted.test.py @@ -0,0 +1,50 @@ +"""Property — builder emitted-pattern equivalence against a hand-rolled reference impl.""" + +import re + +from hypothesis import given +from hypothesis import strategies as strategy + +from edify import RegexBuilder + +_LITERAL_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789-_" + + +@given( + strategy.lists( + strategy.text(alphabet=_LITERAL_ALPHABET, min_size=1, max_size=8), min_size=1, max_size=6 + ) +) +def test_concatenated_string_calls_emit_the_re_escape_concatenation(literal_segments): + builder = RegexBuilder() + for literal in literal_segments: + builder = builder.string(literal) + emitted = builder.to_regex_string() + reference = "".join(re.escape(literal) for literal in literal_segments) + assert emitted == reference + + +@given(strategy.text(alphabet=_LITERAL_ALPHABET, min_size=1, max_size=32)) +def test_string_terminal_matches_the_reference_re_escape_output(literal_value): + emitted = RegexBuilder().string(literal_value).to_regex_string() + reference = re.escape(literal_value) + assert emitted == reference + + +@given(strategy.lists(strategy.sampled_from("abcdef012"), min_size=1, max_size=6, unique=True)) +def test_any_of_chars_emits_a_char_class_containing_every_input_character(class_members): + body = "".join(class_members) + emitted = RegexBuilder().any_of_chars(body).to_regex_string() + reference = f"[{body}]" + assert emitted == reference + + +@given( + strategy.integers(min_value=1, max_value=8), + strategy.sampled_from(["digit", "word", "letter"]), +) +def test_exactly_n_of_a_class_emits_class_with_brace_quantifier(count, class_name): + builder = getattr(RegexBuilder().exactly(count), class_name)() + emitted = builder.to_regex_string() + class_source = {"digit": r"\d", "word": r"\w", "letter": r"[a-zA-Z]"}[class_name] + assert emitted == f"{class_source}{{{count}}}" diff --git a/tests/property/parsing.test.py b/tests/property/parsing.test.py new file mode 100644 index 0000000..393f29d --- /dev/null +++ b/tests/property/parsing.test.py @@ -0,0 +1,76 @@ +"""Property — ``RegexBuilder.from_regex(source)`` roundtrips behaviorally. + +Fuzz the constructs the reverse parser supports and verify that the emitted +pattern from the rebuilt chain matches the same corpus as the original raw +regex string. +""" + +import re + +from hypothesis import assume, given +from hypothesis import strategies as strategy + +from edify import RegexBuilder + +_LITERAL_ALPHABET = "abcdef0123" + + +def _simple_regex_strategy(): + literal_strategy = strategy.text(alphabet=_LITERAL_ALPHABET, min_size=1, max_size=4) + return strategy.one_of( + literal_strategy, + strategy.builds( + lambda body: f"[{body}]", + strategy.text(alphabet=_LITERAL_ALPHABET, min_size=1, max_size=4), + ), + strategy.sampled_from([r"\d", r"\w", r"\s", "."]), + ) + + +_QUANTIFIER_SUFFIX_STRATEGY = strategy.one_of( + strategy.just(""), + strategy.just("+"), + strategy.just("*"), + strategy.just("?"), + strategy.builds(lambda n: f"{{{n}}}", strategy.integers(min_value=1, max_value=4)), +) + + +@given(_simple_regex_strategy(), _QUANTIFIER_SUFFIX_STRATEGY) +def test_from_regex_roundtrip_preserves_match_behavior_on_the_source_alphabet(atom, suffix): + source_pattern = f"{atom}{suffix}" + _assume_compilable(source_pattern) + reconstructed_builder = RegexBuilder.from_regex(source_pattern) + reconstructed_source = reconstructed_builder.to_regex_string() + + original_compiled = re.compile(source_pattern) + reconstructed_compiled = re.compile(reconstructed_source) + + for candidate in _corpus(): + assert bool(original_compiled.search(candidate)) == bool( + reconstructed_compiled.search(candidate) + ) + + +def _assume_compilable(source_pattern): + try: + re.compile(source_pattern) + except re.error: + assume(False) + + +def _corpus(): + return [ + "", + "a", + "abcdef", + "0123", + "abc123", + "abcxyz", + "z", + " ", + "\n", + "aabbcc", + "0", + "9", + ] diff --git a/tests/property/quantifiers.test.py b/tests/property/quantifiers.test.py new file mode 100644 index 0000000..103c94d --- /dev/null +++ b/tests/property/quantifiers.test.py @@ -0,0 +1,59 @@ +"""Property — no chain silently drops a quantifier (count parity).""" + +import re + +from hypothesis import given +from hypothesis import strategies as strategy + +from edify import RegexBuilder + +_QUANTIFIER_ELEMENTS = ["digit", "word", "letter", "any_char"] + + +_ZERO_OR_MORE_METHOD = "zero_or_more" +_ONE_OR_MORE_METHOD = "one_or_more" +_OPTIONAL_METHOD = "optional" + + +_QUANTIFIER_METHOD_STRATEGY = strategy.sampled_from( + [_ZERO_OR_MORE_METHOD, _ONE_OR_MORE_METHOD, _OPTIONAL_METHOD] +) + + +_QUANTIFIER_SUFFIX_PATTERN = re.compile(r"[+*?]|\{[0-9,]+\}") + + +@given(strategy.lists(_QUANTIFIER_METHOD_STRATEGY, min_size=1, max_size=8)) +def test_every_bare_quantifier_call_produces_one_output_quantifier(quantifier_calls): + builder = RegexBuilder() + for quantifier_method in quantifier_calls: + quantifier_bound = getattr(builder, quantifier_method)() + builder = quantifier_bound.digit() + emitted = builder.to_regex_string() + output_quantifier_count = len(_QUANTIFIER_SUFFIX_PATTERN.findall(emitted)) + assert output_quantifier_count == len(quantifier_calls), ( + f"chain declared {len(quantifier_calls)} quantifier calls but emitted " + f"{output_quantifier_count} quantifier suffixes in {emitted!r}" + ) + + +@given( + strategy.lists( + strategy.tuples( + strategy.integers(min_value=1, max_value=8), + strategy.integers(min_value=1, max_value=8), + ), + min_size=1, + max_size=5, + ) +) +def test_between_calls_produce_a_brace_quantifier_per_call(min_max_pairs): + builder = RegexBuilder() + expected_quantifiers = 0 + for lower_bound, extra in min_max_pairs: + upper_bound = lower_bound + extra + builder = builder.between(lower_bound, upper_bound).digit() + expected_quantifiers += 1 + emitted = builder.to_regex_string() + output_quantifier_count = len(_QUANTIFIER_SUFFIX_PATTERN.findall(emitted)) + assert output_quantifier_count == expected_quantifiers diff --git a/tests/property/roundtrip.test.py b/tests/property/roundtrip.test.py new file mode 100644 index 0000000..44e3b12 --- /dev/null +++ b/tests/property/roundtrip.test.py @@ -0,0 +1,63 @@ +"""Property — ``Pattern.from_dict(pattern.to_dict())`` roundtrips every generated pattern.""" + +from hypothesis import given +from hypothesis import strategies as strategy + +from edify import Pattern + +_LITERAL_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789" + + +def _pattern_strategy(): + return strategy.recursive( + strategy.one_of( + strategy.builds( + lambda text: Pattern().string(text), + strategy.text(alphabet=_LITERAL_ALPHABET, min_size=1, max_size=6), + ), + strategy.builds(lambda: Pattern().digit()), + strategy.builds(lambda: Pattern().word()), + strategy.builds(lambda: Pattern().letter()), + strategy.builds( + lambda body: Pattern().any_of_chars(body), + strategy.text(alphabet=_LITERAL_ALPHABET, min_size=1, max_size=4), + ), + ), + lambda children: strategy.one_of( + strategy.builds(_wrap_in_capture, children), + strategy.builds(_wrap_in_group, children), + strategy.builds(_wrap_in_one_or_more, children), + strategy.builds(_wrap_in_optional, children), + ), + max_leaves=4, + ) + + +def _wrap_in_capture(inner): + return Pattern().capture().subexpression(inner).end() + + +def _wrap_in_group(inner): + return Pattern().group().subexpression(inner).end() + + +def _wrap_in_one_or_more(inner): + return Pattern().one_or_more().subexpression(inner) + + +def _wrap_in_optional(inner): + return Pattern().optional().subexpression(inner) + + +@given(_pattern_strategy()) +def test_dict_roundtrip_preserves_the_emitted_regex_string(original_pattern): + document = original_pattern.to_dict() + reconstructed_pattern = Pattern.from_dict(document) + assert reconstructed_pattern.to_regex_string() == original_pattern.to_regex_string() + + +@given(_pattern_strategy()) +def test_json_roundtrip_preserves_the_emitted_regex_string(original_pattern): + payload = original_pattern.to_json() + reconstructed_pattern = Pattern.from_json(payload) + assert reconstructed_pattern.to_regex_string() == original_pattern.to_regex_string() |
