aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authornatsuoto <[email protected]>2026-07-15 15:53:53 +0530
committernatsuoto <[email protected]>2026-07-15 15:53:53 +0530
commit7bb864a7c55d426e28a94d1ce792b34e169f71d8 (patch)
tree83408a9aadbcc7d2625a138ee21134d12c4080f4
parent33e6382dba3603f4d4a1dafeca667caae2983c9c (diff)
downloadedify-7bb864a7c55d426e28a94d1ce792b34e169f71d8.tar.xz
edify-7bb864a7c55d426e28a94d1ce792b34e169f71d8.zip
test(property): Hypothesis harness + property tests for builder emission, roundtrips, quantifier count parity, reverse parser
-rw-r--r--tests/conftest.py43
-rw-r--r--tests/property/emitted.test.py50
-rw-r--r--tests/property/parsing.test.py76
-rw-r--r--tests/property/quantifiers.test.py59
-rw-r--r--tests/property/roundtrip.test.py63
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()