diff options
Diffstat (limited to 'tests/builder')
| -rw-r--r-- | tests/builder/cache.test.py | 69 | ||||
| -rw-r--r-- | tests/builder/diagnose.test.py | 105 | ||||
| -rw-r--r-- | tests/builder/equality.test.py | 87 | ||||
| -rw-r--r-- | tests/builder/properties.test.py | 215 |
4 files changed, 447 insertions, 29 deletions
diff --git a/tests/builder/cache.test.py b/tests/builder/cache.test.py new file mode 100644 index 0000000..80862d3 --- /dev/null +++ b/tests/builder/cache.test.py @@ -0,0 +1,69 @@ +"""Tests for the lazy :class:`Regex` cache on :class:`BuilderCore`.""" + +from edify import Pattern, RegexBuilder + + +def test_repeated_lazy_regex_calls_return_the_same_instance(): + builder = RegexBuilder().digit() + first = builder._lazy_regex() + second = builder._lazy_regex() + third = builder._lazy_regex() + assert first is second + assert second is third + + +def test_repeated_matcher_calls_reuse_the_cached_regex(): + builder = RegexBuilder().digit() + builder.match("1") + cached = builder._cached_regex + builder.search("2") + builder.findall("3") + builder.test("4") + assert builder._cached_regex is cached + + +def test_pattern_lazy_regex_is_memoised_too(): + pattern = Pattern().word() + first = pattern._lazy_regex() + second = pattern._lazy_regex() + assert first is second + + +def test_a_forked_builder_gets_a_fresh_cache_slot(): + original = RegexBuilder().digit() + original.match("1") + assert original._cached_regex is not None + forked = original.fork() + assert forked._cached_regex is None + + +def test_a_copied_builder_gets_a_fresh_cache_slot(): + original = RegexBuilder().digit() + original.match("1") + copied = original.copy() + assert copied._cached_regex is None + + +def test_a_chain_extension_gets_a_fresh_cache_slot(): + original = RegexBuilder().digit() + original.match("1") + extended = original.word() + assert extended._cached_regex is None + + +def test_a_fresh_builder_starts_without_a_cached_regex(): + builder = RegexBuilder() + assert builder._cached_regex is None + + +def test_calling_to_regex_directly_does_not_populate_the_cache(): + builder = RegexBuilder().digit() + _ = builder.to_regex() + assert builder._cached_regex is None + + +def test_lazy_regex_produces_a_regex_that_matches_the_pattern(): + builder = RegexBuilder().digit() + result = builder._lazy_regex() + assert result.source == "\\d" + assert result.match("7") is not None diff --git a/tests/builder/diagnose.test.py b/tests/builder/diagnose.test.py new file mode 100644 index 0000000..c61b3a9 --- /dev/null +++ b/tests/builder/diagnose.test.py @@ -0,0 +1,105 @@ +"""Tests for the diagnostic helpers in :mod:`edify.builder.diagnose`.""" + +from types import SimpleNamespace + +import pytest + +from edify import Pattern, RegexBuilder +from edify.builder.diagnose import _fallback, _frame_display_name, diagnose_unfinished +from edify.errors.comparison import CannotCompareUnfinishedBuilderError + + +def _first_pointer_hint(text: str) -> str | None: + for line in text.splitlines(): + if "->" in line and ".py:" in line: + return line + return None + + +def test_diagnose_returns_none_when_state_is_fully_specified(): + finished = RegexBuilder().digit() + problem = diagnose_unfinished(finished._state, "left operand") + assert problem is None + + +def test_open_group_frame_names_group_in_the_problem_description(): + unfinished = RegexBuilder().group().digit() + with pytest.raises(CannotCompareUnfinishedBuilderError) as excinfo: + _ = unfinished == RegexBuilder() + text = str(excinfo.value) + assert "open `group()`" in text + + +def test_open_capture_frame_names_capture_in_the_problem_description(): + unfinished = RegexBuilder().capture().digit() + with pytest.raises(CannotCompareUnfinishedBuilderError) as excinfo: + _ = unfinished == RegexBuilder() + text = str(excinfo.value) + assert "open `capture()`" in text + + +def test_open_named_capture_frame_names_the_name_in_the_problem_description(): + unfinished = RegexBuilder().named_capture("year").digit() + with pytest.raises(CannotCompareUnfinishedBuilderError) as excinfo: + _ = unfinished == RegexBuilder() + text = str(excinfo.value) + assert 'named_capture("year")' in text + + +def test_open_assert_ahead_frame_names_lookahead(): + unfinished = RegexBuilder().assert_ahead().digit() + with pytest.raises(CannotCompareUnfinishedBuilderError) as excinfo: + _ = unfinished == RegexBuilder() + text = str(excinfo.value) + assert "assert_ahead()" in text + + +def test_open_assert_not_ahead_frame_names_negative_lookahead(): + unfinished = RegexBuilder().assert_not_ahead().digit() + with pytest.raises(CannotCompareUnfinishedBuilderError) as excinfo: + _ = unfinished == RegexBuilder() + text = str(excinfo.value) + assert "assert_not_ahead()" in text + + +def test_open_assert_behind_frame_names_lookbehind(): + unfinished = RegexBuilder().assert_behind().digit() + with pytest.raises(CannotCompareUnfinishedBuilderError) as excinfo: + _ = unfinished == RegexBuilder() + text = str(excinfo.value) + assert "assert_behind()" in text + + +def test_open_assert_not_behind_frame_names_negative_lookbehind(): + unfinished = RegexBuilder().assert_not_behind().digit() + with pytest.raises(CannotCompareUnfinishedBuilderError) as excinfo: + _ = unfinished == RegexBuilder() + text = str(excinfo.value) + assert "assert_not_behind()" in text + + +def test_frame_display_name_falls_back_to_class_name_for_unknown_container(): + mystery_class = type("MysteryContainer", (), {}) + mystery_element = mystery_class() + fake_frame = SimpleNamespace(type_node=mystery_element) + assert _frame_display_name(fake_frame) == "MysteryContainer" + + +def test_fallback_returns_context_when_provided(): + dummy_context = "sentinel" + assert _fallback(dummy_context) == "sentinel" + + +def test_fallback_returns_placeholder_when_context_is_none(): + placeholder = _fallback(None) + assert placeholder.filename == "<unknown>" + assert placeholder.lineno == 0 + assert placeholder.source_line == "" + + +def test_dangling_quantifier_on_pattern_reports_quantifier_name(): + unfinished_pattern = Pattern().one_or_more() + with pytest.raises(CannotCompareUnfinishedBuilderError) as excinfo: + _ = unfinished_pattern == Pattern() + text = str(excinfo.value) + assert "pending `.one_or_more()`" in text diff --git a/tests/builder/equality.test.py b/tests/builder/equality.test.py index 717d1ae..8482b51 100644 --- a/tests/builder/equality.test.py +++ b/tests/builder/equality.test.py @@ -1,6 +1,12 @@ """Tests for value-based ``__eq__`` and ``__hash__`` on :class:`BuilderCore`.""" +import pytest + from edify import Pattern, RegexBuilder +from edify.errors.comparison import ( + CannotCompareUnfinishedBuilderError, + CannotHashUnfinishedBuilderError, +) def test_two_builders_with_the_same_chain_are_equal(): @@ -48,3 +54,84 @@ def test_hash_of_two_flags_that_differ_are_distinct(): b = RegexBuilder().digit() assert a != b assert hash(a) != hash(b) + + +def test_equality_uses_emitted_pattern_not_underlying_state(): + from_chain = RegexBuilder().string("hi") + from_string_kwarg = RegexBuilder().string("hi") + assert from_chain.to_regex_string() == from_string_kwarg.to_regex_string() + assert from_chain == from_string_kwarg + + +def test_hash_uses_emitted_pattern_and_flags_tuple(): + a = RegexBuilder().string("hi") + b = RegexBuilder().string("hi") + assert hash(a) == hash((a.to_regex_string(), a._state.flags)) + assert hash(a) == hash(b) + + +def test_equality_raises_when_left_operand_has_open_frames(): + left = RegexBuilder().any_of() + right = RegexBuilder().digit() + with pytest.raises(CannotCompareUnfinishedBuilderError): + _ = left == right + + +def test_equality_raises_when_right_operand_has_open_frames(): + left = RegexBuilder().digit() + right = RegexBuilder().any_of() + with pytest.raises(CannotCompareUnfinishedBuilderError): + _ = left == right + + +def test_equality_raises_when_both_operands_are_unfinished(): + left = RegexBuilder().any_of() + right = RegexBuilder().exactly(3) + with pytest.raises(CannotCompareUnfinishedBuilderError): + _ = left == right + + +def test_hash_raises_when_frames_are_open(): + with pytest.raises(CannotHashUnfinishedBuilderError): + hash(RegexBuilder().any_of()) + + +def test_equality_raises_when_a_quantifier_is_dangling(): + left = RegexBuilder().exactly(3) + right = RegexBuilder().digit() + with pytest.raises(CannotCompareUnfinishedBuilderError): + _ = left == right + + +def test_hash_raises_when_a_quantifier_is_dangling(): + with pytest.raises(CannotHashUnfinishedBuilderError): + hash(RegexBuilder().exactly(3)) + + +def test_compare_error_message_names_the_open_frame(): + with pytest.raises(CannotCompareUnfinishedBuilderError) as excinfo: + _ = RegexBuilder().named_capture("domain") == RegexBuilder().digit() + assert 'named_capture("domain")' in str(excinfo.value) + assert "frame opened here" in str(excinfo.value) + assert ".end()" in str(excinfo.value) + + +def test_compare_error_message_names_the_dangling_quantifier(): + with pytest.raises(CannotCompareUnfinishedBuilderError) as excinfo: + _ = RegexBuilder().digit() == RegexBuilder().exactly(4) + assert "exactly(4)" in str(excinfo.value) + assert "no element follows" in str(excinfo.value) + assert ".digit()" in str(excinfo.value) + + +def test_hash_error_message_names_the_specific_problem(): + with pytest.raises(CannotHashUnfinishedBuilderError) as excinfo: + hash(RegexBuilder().at_least(2)) + assert "at_least(2)" in str(excinfo.value) + + +def test_error_message_shows_a_source_pointer_when_called_from_user_code(): + with pytest.raises(CannotHashUnfinishedBuilderError) as excinfo: + hash(RegexBuilder().any_of()) + assert "-->" in str(excinfo.value) + assert __file__ in str(excinfo.value) diff --git a/tests/builder/properties.test.py b/tests/builder/properties.test.py index 24f5d01..87770fa 100644 --- a/tests/builder/properties.test.py +++ b/tests/builder/properties.test.py @@ -1,15 +1,57 @@ -"""Property assertion — no chain ever silently drops a quantifier. +"""Property assertions — no chain ever silently drops or duplicates an element. -For any list of ``(quantifier method, args)`` calls, each immediately -followed by a leaf-element call, the emitted regex is the concatenation -of ``<element><suffix>`` fragments in the same order — no quantifier is -lost, none appears twice. +Every quantifier, group, capture, named capture, and subexpression in a +randomly-generated composition emits its own fragment exactly once and in +the correct place in the output regex string. """ +from __future__ import annotations + +from dataclasses import dataclass + from hypothesis import given from hypothesis import strategies as st -from edify import RegexBuilder +from edify import Pattern, RegexBuilder + + +@dataclass(frozen=True) +class LeafNode: + """A single element with an optional quantifier attached before it.""" + + element_name: str + element_args: tuple[int, ...] + element_regex: str + quantifier: tuple[str, tuple[int, ...], str] | None + + +@dataclass(frozen=True) +class GroupNode: + """A non-capturing group wrapping ``children``.""" + + children: tuple[object, ...] + + +@dataclass(frozen=True) +class CaptureNode: + """An unnamed capture group wrapping ``children``.""" + + children: tuple[object, ...] + + +@dataclass(frozen=True) +class NamedCaptureNode: + """A named-capture group wrapping ``children``; the name is assigned deterministically.""" + + children: tuple[object, ...] + + +@dataclass(frozen=True) +class SubexpressionNode: + """A subexpression built as a separate ``Pattern`` and merged into the parent.""" + + children: tuple[object, ...] + _QUANTIFIER_STRATEGIES: list[st.SearchStrategy[tuple[str, tuple[int, ...], str]]] = [ st.just(("optional", (), "?")), @@ -44,30 +86,145 @@ _ELEMENT_STRATEGIES = [ st.just(("alphanumeric", (), "[a-zA-Z0-9]")), ] -_quantifier_element_pair = st.tuples( - st.one_of(*_QUANTIFIER_STRATEGIES), - st.one_of(*_ELEMENT_STRATEGIES), -) +_element_pick = st.one_of(*_ELEMENT_STRATEGIES) +_quantifier_pick_or_none = st.one_of(st.none(), *_QUANTIFIER_STRATEGIES) + + +_ElementCall = tuple[str, tuple[int, ...], str] +_QuantifierCall = tuple[str, tuple[int, ...], str] + + +def _build_leaf(pair: tuple[_ElementCall, _QuantifierCall | None]) -> LeafNode: + element, quantifier = pair + element_name, element_args, element_regex = element + return LeafNode( + element_name=element_name, + element_args=element_args, + element_regex=element_regex, + quantifier=quantifier, + ) + + +_leaf_node_strategy = st.tuples(_element_pick, _quantifier_pick_or_none).map(_build_leaf) + +def _extend(children_strategy: st.SearchStrategy) -> st.SearchStrategy: + children_list = st.lists(children_strategy, min_size=1, max_size=4) + group_nodes = children_list.map(lambda children: GroupNode(tuple(children))) + capture_nodes = children_list.map(lambda children: CaptureNode(tuple(children))) + named_capture_nodes = children_list.map(lambda children: NamedCaptureNode(tuple(children))) + subexpression_nodes = children_list.map(lambda children: SubexpressionNode(tuple(children))) + return st.one_of(group_nodes, capture_nodes, named_capture_nodes, subexpression_nodes) -@given(st.lists(_quantifier_element_pair, min_size=1, max_size=8)) -def test_every_quantifier_chain_call_produces_exactly_one_output_quantifier(pairs): + +_node_strategy = st.recursive(_leaf_node_strategy, _extend, max_leaves=6) + + +def _apply_sequence( + builder, + nodes: list[object], + name_counter: int, +) -> tuple[object, str, int]: + """Apply ``nodes`` to ``builder`` in order; return the updated triple.""" + fragments: list[str] = [] + for node in nodes: + builder, fragment, name_counter = _apply_node(builder, node, name_counter) + fragments.append(fragment) + combined_regex = "".join(fragments) + return builder, combined_regex, name_counter + + +def _apply_node(builder, node, name_counter: int) -> tuple[object, str, int]: + """Dispatch on ``node`` type, applying it to ``builder`` and returning the fragment.""" + if isinstance(node, LeafNode): + return _apply_leaf(builder, node, name_counter) + if isinstance(node, GroupNode): + return _apply_group(builder, node, name_counter) + if isinstance(node, CaptureNode): + return _apply_capture(builder, node, name_counter) + if isinstance(node, NamedCaptureNode): + return _apply_named_capture(builder, node, name_counter) + return _apply_subexpression(builder, node, name_counter) + + +def _apply_leaf(builder, node: LeafNode, name_counter: int) -> tuple[object, str, int]: + """Apply a single leaf element, optionally preceded by a quantifier.""" + if node.quantifier is None: + builder_after_element = getattr(builder, node.element_name)(*node.element_args) + return builder_after_element, node.element_regex, name_counter + quantifier_name, quantifier_args, quantifier_suffix = node.quantifier + builder_after_quantifier = getattr(builder, quantifier_name)(*quantifier_args) + builder_after_element = getattr(builder_after_quantifier, node.element_name)(*node.element_args) + fragment = f"{node.element_regex}{quantifier_suffix}" + return builder_after_element, fragment, name_counter + + +def _apply_group(builder, node: GroupNode, name_counter: int) -> tuple[object, str, int]: + """Apply a non-capturing group around ``node.children``.""" + builder_opened = builder.group() + builder_inner, inner_regex, name_counter = _apply_sequence( + builder_opened, list(node.children), name_counter + ) + builder_closed = builder_inner.end() + return builder_closed, f"(?:{inner_regex})", name_counter + + +def _apply_capture(builder, node: CaptureNode, name_counter: int) -> tuple[object, str, int]: + """Apply an unnamed capture group around ``node.children``.""" + builder_opened = builder.capture() + builder_inner, inner_regex, name_counter = _apply_sequence( + builder_opened, list(node.children), name_counter + ) + builder_closed = builder_inner.end() + return builder_closed, f"({inner_regex})", name_counter + + +def _apply_named_capture( + builder, + node: NamedCaptureNode, + name_counter: int, +) -> tuple[object, str, int]: + """Apply a named-capture group with a deterministically-assigned name.""" + assigned_name = f"n{name_counter}" + next_counter = name_counter + 1 + builder_opened = builder.named_capture(assigned_name) + builder_inner, inner_regex, next_counter = _apply_sequence( + builder_opened, list(node.children), next_counter + ) + builder_closed = builder_inner.end() + return builder_closed, f"(?P<{assigned_name}>{inner_regex})", next_counter + + +def _apply_subexpression( + builder, + node: SubexpressionNode, + name_counter: int, +) -> tuple[object, str, int]: + """Apply a subexpression built as an independent ``Pattern`` and merged in.""" + sub_pattern = Pattern() + sub_pattern_finished, sub_regex, next_counter = _apply_sequence( + sub_pattern, list(node.children), name_counter + ) + builder_after_merge = builder.subexpression(sub_pattern_finished) + return builder_after_merge, sub_regex, next_counter + + +@given(st.lists(_leaf_node_strategy, min_size=1, max_size=8)) +def test_bare_element_chain_emits_the_concatenation_of_element_fragments(nodes): + builder = RegexBuilder() + builder_after, expected_regex, _ = _apply_sequence(builder, list(nodes), 0) + assert builder_after.to_regex_string() == expected_regex + + +@given(st.lists(_leaf_node_strategy, min_size=1, max_size=8)) +def test_every_quantifier_chain_call_produces_exactly_one_output_quantifier(nodes): builder = RegexBuilder() - expected_fragments: list[str] = [] - for quantifier_call, element_call in pairs: - quantifier_name, quantifier_args, quantifier_suffix = quantifier_call - element_name, element_args, element_regex = element_call - builder = getattr(builder, quantifier_name)(*quantifier_args) - builder = getattr(builder, element_name)(*element_args) - expected_fragments.append(f"{element_regex}{quantifier_suffix}") - assert builder.to_regex_string() == "".join(expected_fragments) - - -@given(st.lists(st.one_of(*_ELEMENT_STRATEGIES), min_size=1, max_size=8)) -def test_bare_element_chain_emits_the_concatenation_of_element_fragments(elements): + builder_after, expected_regex, _ = _apply_sequence(builder, list(nodes), 0) + assert builder_after.to_regex_string() == expected_regex + + +@given(st.lists(_node_strategy, min_size=1, max_size=6)) +def test_composition_over_groups_captures_and_subexpressions_is_faithful(nodes): builder = RegexBuilder() - expected_fragments: list[str] = [] - for element_name, element_args, element_regex in elements: - builder = getattr(builder, element_name)(*element_args) - expected_fragments.append(element_regex) - assert builder.to_regex_string() == "".join(expected_fragments) + builder_after, expected_regex, _ = _apply_sequence(builder, list(nodes), 0) + assert builder_after.to_regex_string() == expected_regex |
