diff options
Diffstat (limited to 'tests')
| -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 | ||||
| -rw-r--r-- | tests/errors/context.test.py | 107 | ||||
| -rw-r--r-- | tests/errors/errors.test.py | 149 | ||||
| -rw-r--r-- | tests/errors/formatting.test.py | 127 | ||||
| -rw-r--r-- | tests/errors/quantifier.test.py | 12 | ||||
| -rw-r--r-- | tests/introspect/ascii.test.py | 531 | ||||
| -rw-r--r-- | tests/introspect/explain.test.py | 801 | ||||
| -rw-r--r-- | tests/introspect/graphviz.test.py | 355 | ||||
| -rw-r--r-- | tests/introspect/verbose.test.py | 453 | ||||
| -rw-r--r-- | tests/introspect/visualize.test.py | 92 | ||||
| -rw-r--r-- | tests/result/regex.test.py | 29 |
14 files changed, 3064 insertions, 68 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 diff --git a/tests/errors/context.test.py b/tests/errors/context.test.py new file mode 100644 index 0000000..fff9e09 --- /dev/null +++ b/tests/errors/context.test.py @@ -0,0 +1,107 @@ +"""Tests for the caller-source-location capture helpers in :mod:`edify.errors.context`.""" + +from unittest import mock + +from edify.errors.context import ( + CallerContext, + _context_for_frame, + capture_caller_context, +) + + +class _FakeCode: + """A stand-in for ``types.CodeType`` used to force specific ``co_positions`` behaviour.""" + + def __init__(self, filename: str, positions: list[tuple]) -> None: + self.co_filename = filename + self._positions = list(positions) + + def co_positions(self): + return iter(self._positions) + + +class _FakeFrame: + """A stand-in for ``types.FrameType`` shaped for :func:`_context_for_frame`.""" + + def __init__( + self, + filename: str, + f_lineno: int, + f_lasti: int, + positions: list[tuple], + ) -> None: + self.f_code = _FakeCode(filename, positions) + self.f_lineno = f_lineno + self.f_lasti = f_lasti + + +def test_capture_caller_context_returns_none_when_every_frame_is_inside_edify(): + with mock.patch("edify.errors.context.sys._getframe", return_value=None): + assert capture_caller_context() is None + + +def test_context_for_frame_uses_lineno_fallback_when_instruction_index_out_of_range(): + frame = _FakeFrame( + filename="/tmp/fake.py", + f_lineno=42, + f_lasti=999, + positions=[], + ) + context = _context_for_frame(frame) + assert context.lineno == 42 + assert context.colno == 1 + assert context.end_colno == 1 + + +def test_context_for_frame_defaults_start_line_when_position_start_line_is_none(): + frame = _FakeFrame( + filename="/tmp/fake.py", + f_lineno=99, + f_lasti=0, + positions=[(None, None, None, None)], + ) + context = _context_for_frame(frame) + assert context.lineno == 99 + + +def test_context_for_frame_defaults_end_line_when_position_end_line_is_none(): + frame = _FakeFrame( + filename="/tmp/fake.py", + f_lineno=7, + f_lasti=0, + positions=[(5, None, 2, 4)], + ) + context = _context_for_frame(frame) + assert context.lineno == 5 + assert context.colno == 3 + assert context.end_colno == 5 + + +def test_context_for_frame_defaults_start_col_when_position_start_col_is_none(): + frame = _FakeFrame( + filename="/tmp/fake.py", + f_lineno=1, + f_lasti=0, + positions=[(1, 1, None, 5)], + ) + context = _context_for_frame(frame) + assert context.colno == 1 + assert context.end_colno == 6 + + +def test_context_for_frame_defaults_end_col_when_position_end_col_is_none(): + frame = _FakeFrame( + filename="/tmp/fake.py", + f_lineno=1, + f_lasti=0, + positions=[(1, 1, 3, None)], + ) + context = _context_for_frame(frame) + assert context.colno == 4 + assert context.end_colno == 4 + + +def test_caller_context_dataclass_is_frozen_and_holds_all_five_fields(): + context = CallerContext(filename="a.py", lineno=1, colno=1, end_colno=5, source_line="x = 1") + assert context.filename == "a.py" + assert context.source_line == "x = 1" diff --git a/tests/errors/errors.test.py b/tests/errors/errors.test.py index 6472469..74acaa4 100644 --- a/tests/errors/errors.test.py +++ b/tests/errors/errors.test.py @@ -35,131 +35,212 @@ from edify.errors.structure import ( def test_start_input_already_defined_outside_subexpression(): error = StartInputAlreadyDefinedError() - assert "already has a start" in str(error) - assert "ignore_start_and_end" not in str(error) + text = str(error) + assert "start_of_input has already been added" in text + assert "help: remove the duplicate .start_of_input()" in text + assert "ignore_start_and_end" not in text def test_start_input_already_defined_in_subexpression(): error = StartInputAlreadyDefinedError(in_subexpression=True) - assert "ignore_start_and_end" in str(error) + text = str(error) + assert "start_of_input has already been added" in text + assert "ignore_start_and_end=True" in text def test_end_input_already_defined_outside_subexpression(): error = EndInputAlreadyDefinedError() - assert "already has an end" in str(error) - assert "ignore_start_and_end" not in str(error) + text = str(error) + assert "end_of_input has already been added" in text + assert "help: remove the duplicate .end_of_input()" in text + assert "ignore_start_and_end" not in text def test_end_input_already_defined_in_subexpression(): error = EndInputAlreadyDefinedError(in_subexpression=True) - assert "ignore_start_and_end" in str(error) + text = str(error) + assert "end_of_input has already been added" in text + assert "ignore_start_and_end=True" in text def test_cannot_define_start_after_end(): error = CannotDefineStartAfterEndError() - assert "start of input after defining an end" in str(error) + text = str(error) + assert "start_of_input cannot follow end_of_input" in text + assert "move .start_of_input() to before" in text -def test_invalid_total_capture_groups_index(): +def test_invalid_total_capture_groups_index_reports_out_of_range(): error = InvalidTotalCaptureGroupsIndexError(5, 3) - assert "Invalid index #5" in str(error) - assert "only 3 capture groups" in str(error) + text = str(error) + assert "back_reference index #5 is out of range" in text + assert "3 capture groups" in text + assert "valid indices are 1 to 3" in text + + +def test_invalid_total_capture_groups_index_with_no_capture_groups_prompts_add_one(): + error = InvalidTotalCaptureGroupsIndexError(1, 0) + text = str(error) + assert "no capture groups yet" in text + assert "add a .capture()" in text def test_must_be_a_string(): error = MustBeAStringError("Name", "int") - assert "Name must be a string" in str(error) - assert "int" in str(error) + text = str(error) + assert "Name must be a string" in text + assert "int" in text + assert "convert the value with str(...)" in text def test_must_be_one_character(): error = MustBeOneCharacterError("Value") - assert "Value must be one character long" in str(error) + text = str(error) + assert "Value must be one character long" in text def test_must_be_single_character(): error = MustBeSingleCharacterError("Value", "str") - assert "Value must be a single character" in str(error) - assert "str" in str(error) + text = str(error) + assert "Value must be a single character" in text + assert "str" in text def test_must_be_positive_integer(): error = MustBePositiveIntegerError("count") - assert "count must be a positive integer" in str(error) + text = str(error) + assert "count must be a positive integer" in text def test_must_be_integer_greater_than_zero(): error = MustBeIntegerGreaterThanZeroError("x") - assert "x must be an integer greater than zero" in str(error) + text = str(error) + assert "x must be an integer greater than zero" in text def test_must_be_instance(): error = MustBeInstanceError("Expression", "str", "RegexBuilder") - assert "Expression must be an instance of RegexBuilder" in str(error) - assert "str" in str(error) + text = str(error) + assert "Expression must be an instance of RegexBuilder" in text + assert "str" in text -def test_must_have_a_smaller_value(): +def test_must_have_a_smaller_value_reports_the_codepoints(): error = MustHaveASmallerValueError("z", "a") - assert "z must have a smaller character value than a" in str(error) + text = str(error) + assert "range bounds are inverted" in text + assert "'z'" in text + assert "'a'" in text + assert "= 122" in text + assert "= 97" in text def test_must_be_less_than(): error = MustBeLessThanError("X", "Y") - assert "X must be less than Y" in str(error) + text = str(error) + assert "X must be less than Y" in text def test_must_be_at_least_two_operands(): error = MustBeAtLeastTwoOperandsError("any_of") - assert "any_of requires at least two operands" in str(error) + text = str(error) + assert "any_of requires at least two operands" in text def test_must_be_at_least_one_literal(): error = MustBeAtLeastOneLiteralError("one_of") - assert "one_of requires at least one literal" in str(error) + text = str(error) + assert "one_of requires at least one literal" in text def test_name_not_valid(): error = NameNotValidError("bad name") - assert "Name bad name is not valid" in str(error) + text = str(error) + assert "'bad name' is not a valid identifier" in text + assert "letters, digits, and underscores" in text def test_cannot_create_duplicate_named_group(): error = CannotCreateDuplicateNamedGroupError("dup") - assert 'Can not create duplicate named group "dup"' in str(error) + text = str(error) + assert "named group 'dup' already exists" in text + assert ".named_back_reference('dup')" in text def test_named_group_does_not_exist(): error = NamedGroupDoesNotExistError("missing") - assert 'Named group "missing" does not exist' in str(error) + text = str(error) + assert "named group 'missing' does not exist" in text + assert ".named_capture('missing')" in text def test_cannot_end_while_building_root_expression(): error = CannotEndWhileBuildingRootExpressionError() - assert "Can not end while building the root expression" in str(error) + text = str(error) + assert "cannot .end() while building the root expression" in text + assert "no matching opener" in text or "no frame to close" in text def test_cannot_call_subexpression(): error = CannotCallSubexpressionError("capture") - assert "Can not call subexpression" in str(error) - assert "capture" in str(error) + text = str(error) + assert "cannot merge a subexpression that has an unclosed frame" in text + assert "capture" in text def test_unknown_element_type(): error = UnknownElementTypeError("WeirdElement") - assert "WeirdElement" in str(error) + text = str(error) + assert "unknown element type 'WeirdElement'" in text def test_non_fusable_element(): error = NonFusableElementError("DigitElement") - assert "Cannot fuse element of type DigitElement" in str(error) + text = str(error) + assert "cannot fuse element 'DigitElement' into a character class" in text def test_unexpected_frame_type(): error = UnexpectedFrameTypeError("DigitElement") - assert "Stack frame anchored at unexpected element type DigitElement" in str(error) + text = str(error) + assert "stack frame anchored at unexpected element 'DigitElement'" in text def test_failed_to_compile_regex(): error = FailedToCompileRegexError("missing )") - assert "Cannot compile regex: missing )" in str(error) + text = str(error) + assert "rejected by the re engine" in text + assert "missing )" in text + + +def test_every_annotated_error_message_starts_with_error_prefix(): + errors = [ + StartInputAlreadyDefinedError(), + CannotDefineStartAfterEndError(), + EndInputAlreadyDefinedError(), + InvalidTotalCaptureGroupsIndexError(1, 0), + MustBeAStringError("X", "int"), + MustBeOneCharacterError("X"), + MustBeSingleCharacterError("X", "int"), + MustBePositiveIntegerError("X"), + MustBeIntegerGreaterThanZeroError("X"), + MustBeInstanceError("X", "int", "Y"), + MustHaveASmallerValueError("z", "a"), + MustBeLessThanError("A", "B"), + MustBeAtLeastTwoOperandsError("f"), + MustBeAtLeastOneLiteralError("f"), + NameNotValidError("x"), + CannotCreateDuplicateNamedGroupError("x"), + NamedGroupDoesNotExistError("x"), + CannotEndWhileBuildingRootExpressionError(), + CannotCallSubexpressionError("capture"), + UnknownElementTypeError("X"), + NonFusableElementError("X"), + UnexpectedFrameTypeError("X"), + FailedToCompileRegexError("boom"), + ] + for error in errors: + text = str(error) + assert text.startswith("error:") + assert "help:" in text + assert "= note:" in text diff --git a/tests/errors/formatting.test.py b/tests/errors/formatting.test.py new file mode 100644 index 0000000..314aff1 --- /dev/null +++ b/tests/errors/formatting.test.py @@ -0,0 +1,127 @@ +"""Tests for the message formatter helpers in :mod:`edify.errors.formatting`.""" + +from unittest import mock + +from edify.errors.context import CallerContext +from edify.errors.formatting import ( + FixInsertion, + Problem, + compose_annotated_message, + format_error, + format_fix_block, + format_help_header, + format_note_line, + format_pointer_block, + format_problem, + format_problem_header, +) + + +def _context(source_line: str = " x = do_the_thing(1, 2, 3)", colno: int = 9) -> CallerContext: + return CallerContext( + filename="/tmp/user_code.py", + lineno=42, + colno=colno, + end_colno=colno + 15, + source_line=source_line, + ) + + +def test_format_error_with_only_summary_returns_bare_header(): + assert format_error("boom") == "error: boom" + + +def test_format_error_with_summary_and_blocks_joins_them_with_blank_line(): + output = format_error("boom", "first block", "second block") + assert output == "error: boom\n\nfirst block\n\nsecond block" + + +def test_format_error_skips_empty_blocks(): + output = format_error("boom", "", "only real block", "") + assert output == "error: boom\n\nonly real block" + + +def test_format_pointer_block_draws_caret_at_caller_column(): + context = _context() + block = format_pointer_block(context, "here") + lines = block.splitlines() + assert lines[0] == " --> /tmp/user_code.py:42:9" + assert "^^^^^^^^^^^^^^^ here" in block + + +def test_format_pointer_block_has_at_least_one_caret_when_span_is_empty(): + context = CallerContext( + filename="/tmp/x.py", + lineno=1, + colno=5, + end_colno=5, + source_line="abcde", + ) + block = format_pointer_block(context, "point") + assert "^ point" in block + + +def test_format_note_line_prepends_the_note_prefix(): + assert format_note_line("something is off") == " = note: something is off" + + +def test_format_problem_header_prepends_problem_prefix(): + assert format_problem_header("stuff") == "problem: stuff" + + +def test_format_help_header_prepends_help_prefix(): + assert format_help_header("do X") == "help: do X" + + +def test_format_fix_block_inserts_text_at_the_target_column(): + context = _context(source_line="value = compute()", colno=1) + insertion = FixInsertion(column=9, text="new_") + block = format_fix_block(context, insertion) + assert "value = new_compute()" in block + assert "++++" in block + + +def test_format_problem_composes_header_pointer_help_and_fix(): + problem_context = _context(source_line="pattern = build()", colno=11) + fix_context = _context(source_line="pattern = build()", colno=11) + fix_insertion = FixInsertion(column=11, text=".digit()") + problem = Problem( + description="missing operand", + problem_context=problem_context, + problem_hint="no operand added", + help_summary="add .digit() before .build()", + fix_context=fix_context, + fix_insertion=fix_insertion, + ) + output = format_problem(problem) + assert "problem: missing operand" in output + assert "no operand added" in output + assert "help: add .digit() before .build()" in output + assert "++++++++" in output + + +def test_compose_annotated_message_when_caller_context_is_available(): + output = compose_annotated_message( + summary="bad thing happened", + trigger_hint="right here", + note="because reasons", + help_line="help: do X", + ) + assert output.startswith("error: bad thing happened") + assert "-->" in output + assert "= note: because reasons" in output + assert output.rstrip().endswith("help: do X") + + +def test_compose_annotated_message_falls_back_when_caller_context_is_none(): + with mock.patch("edify.errors.formatting.capture_caller_context", return_value=None): + output = compose_annotated_message( + summary="bad thing happened", + trigger_hint="here", + note="because reasons", + help_line="help: do X", + ) + assert output.startswith("error: bad thing happened") + assert "-->" not in output + assert "= note: because reasons" in output + assert output.rstrip().endswith("help: do X") diff --git a/tests/errors/quantifier.test.py b/tests/errors/quantifier.test.py index 27b5ddf..1256a68 100644 --- a/tests/errors/quantifier.test.py +++ b/tests/errors/quantifier.test.py @@ -42,14 +42,15 @@ def test_to_regex_raises_when_a_bare_quantifier_has_no_operand(): def test_dangling_message_hints_at_appending_an_operand(): - with pytest.raises(DanglingQuantifierError, match="Append an element"): + with pytest.raises(DanglingQuantifierError, match="append the element"): RegexBuilder().exactly(3).to_regex_string() def test_dangling_quantifier_error_message_contains_expected_text(): error = DanglingQuantifierError() - assert "Dangling quantifier" in str(error) - assert "no operand" in str(error) + text = str(error) + assert "dangling quantifier" in text + assert "no operand" in text def test_stacking_one_or_more_over_exactly_raises(): @@ -84,5 +85,6 @@ def test_a_valid_quantifier_element_quantifier_element_chain_works(): def test_stacked_quantifier_error_message_contains_expected_text(): error = StackedQuantifierError() - assert "stack" in str(error) - assert "pending" in str(error) + text = str(error) + assert "stack a quantifier" in text + assert "pending quantifier" in text diff --git a/tests/introspect/ascii.test.py b/tests/introspect/ascii.test.py new file mode 100644 index 0000000..c2e3d68 --- /dev/null +++ b/tests/introspect/ascii.test.py @@ -0,0 +1,531 @@ +"""Tests for the ASCII railroad-diagram renderer in :mod:`edify.introspect.ascii`.""" + +from edify import RegexBuilder +from edify.elements.types.captures import ( + BackReferenceElement, + CaptureElement, + NamedBackReferenceElement, + NamedCaptureElement, +) +from edify.elements.types.chars import ( + AnyOfCharsElement, + AnythingButCharsElement, + AnythingButRangeElement, + AnythingButStringElement, + CharElement, + RangeElement, + StringElement, +) +from edify.elements.types.groups import ( + AnyOfElement, + AssertAheadElement, + AssertBehindElement, + AssertNotAheadElement, + AssertNotBehindElement, + GroupElement, + SubexpressionElement, +) +from edify.elements.types.leaves import ( + AlphanumericElement, + AnyCharElement, + CarriageReturnElement, + DigitElement, + EndOfInputElement, + LetterElement, + LowercaseElement, + NewLineElement, + NonDigitElement, + NonWhitespaceCharElement, + NonWordBoundaryElement, + NonWordElement, + NoopElement, + NullByteElement, + StartOfInputElement, + TabElement, + UppercaseElement, + WhitespaceCharElement, + WordBoundaryElement, + WordElement, +) +from edify.elements.types.quantifiers import ( + AtLeastElement, + AtMostElement, + BetweenElement, + BetweenLazyElement, + ExactlyElement, + OneOrMoreElement, + OneOrMoreLazyElement, + OptionalElement, + ZeroOrMoreElement, + ZeroOrMoreLazyElement, +) +from edify.introspect.ascii import ( + _looks_like_single_box, + _pad_right, + _pluralize, + _widen, + render_ascii, +) +from edify.introspect.types import Diagram + + +def _render(*elements) -> str: + return render_ascii(tuple(elements)) + + +def test_empty_pattern_renders_start_arrow_end(): + assert _render() == (" +-------+ +-----+\n | START |-->| END |\n +-------+ +-----+") + + +def test_single_digit_pattern(): + output = _render(DigitElement()) + assert output == ( + " +-------+ +-------+ +-----+\n" + " | START |-->| digit |-->| END |\n" + " +-------+ +-------+ +-----+" + ) + + +def test_one_or_more_digit_reads_as_plural_english(): + output = _render(OneOrMoreElement(child=DigitElement())) + assert "one or more digits" in output + assert "START" in output + assert "END" in output + + +def test_exactly_four_digits_reads_as_count_plural(): + output = _render(ExactlyElement(times=4, child=DigitElement())) + assert "4 digits" in output + + +def test_exactly_one_digit_stays_singular(): + output = _render(ExactlyElement(times=1, child=DigitElement())) + assert "1 digit" in output + assert "1 digits" not in output + + +def test_start_of_input_reads_as_text_starts_here(): + output = _render(StartOfInputElement()) + assert "text starts here" in output + + +def test_end_of_input_reads_as_text_ends_here(): + output = _render(EndOfInputElement()) + assert "text ends here" in output + + +def test_character_literal_strips_regex_escape_for_display(): + output = _render(CharElement(value="\\-")) + assert '"-"' in output + assert '"\\-"' not in output + + +def test_string_literal_renders_in_quotes(): + output = _render(StringElement(value="hello")) + assert '"hello"' in output + + +def test_alternation_lays_out_as_fork_and_merge(): + output = _render( + AnyOfElement( + children=( + StringElement(value="cat"), + StringElement(value="dog"), + StringElement(value="fish"), + ) + ) + ) + lines = output.splitlines() + assert any("+--->" in line for line in lines) + assert any("----+" in line for line in lines) + assert any('"cat"' in line for line in lines) + assert any('"dog"' in line for line in lines) + assert any('"fish"' in line for line in lines) + + +def test_alternation_widens_smaller_boxes_to_match_widest(): + output = _render( + AnyOfElement( + children=( + StringElement(value="cat"), + StringElement(value="fish"), + ) + ) + ) + assert output.count("+--------+") == 4 + assert '| "cat" |' in output + assert '| "fish" |' in output + + +def test_alternation_with_single_branch_still_renders(): + output = _render(AnyOfElement(children=(StringElement(value="only"),))) + assert '"only"' in output + + +def test_named_capture_places_caption_below_child(): + output = _render( + NamedCaptureElement( + name="year", + children=(ExactlyElement(times=4, child=DigitElement()),), + ) + ) + lines = output.splitlines() + assert any('(saved as "year")' in line for line in lines) + assert any("4 digits" in line for line in lines) + + +def test_unnamed_capture_places_captured_caption_below_child(): + output = _render(CaptureElement(children=(DigitElement(),))) + assert "(captured)" in output + + +def test_group_places_grouped_caption_below_child(): + output = _render(GroupElement(children=(DigitElement(),))) + assert "(grouped)" in output + + +def test_subexpression_flattens_into_the_sequence(): + output = _render(SubexpressionElement(children=(DigitElement(), DigitElement()))) + assert output.count("digit") == 2 + + +def test_backreference_reads_as_match_same_text_as_group_n(): + output = _render(BackReferenceElement(index=1)) + assert "match same text as group 1" in output + + +def test_named_backreference_reads_as_match_same_text_as_name(): + output = _render(NamedBackReferenceElement(name="year")) + assert 'match same text as "year"' in output + + +def test_assert_ahead_caption(): + output = _render(AssertAheadElement(children=(DigitElement(),))) + assert "(must be followed by)" in output + + +def test_assert_not_ahead_caption(): + output = _render(AssertNotAheadElement(children=(DigitElement(),))) + assert "(must NOT be followed by)" in output + + +def test_assert_behind_caption(): + output = _render(AssertBehindElement(children=(DigitElement(),))) + assert "(must be preceded by)" in output + + +def test_assert_not_behind_caption(): + output = _render(AssertNotBehindElement(children=(DigitElement(),))) + assert "(must NOT be preceded by)" in output + + +def test_range_element_reads_as_from_x_to_y(): + output = _render(RangeElement(start="a", end="z")) + assert 'from "a" to "z"' in output + + +def test_any_of_chars_reads_as_any_of_value(): + output = _render(AnyOfCharsElement(value="abc")) + assert 'any of "abc"' in output + + +def test_anything_but_chars_reads_as_except_value(): + output = _render(AnythingButCharsElement(value="abc")) + assert 'except "abc"' in output + + +def test_anything_but_range_reads_as_outside_range(): + output = _render(AnythingButRangeElement(start="a", end="z")) + assert 'outside "a"-"z"' in output + + +def test_anything_but_string_reads_as_except_string(): + output = _render(AnythingButStringElement(value="stop")) + assert 'except the string "stop"' in output + + +def test_optional_digit_reads_as_optional_singular(): + output = _render(OptionalElement(child=DigitElement())) + assert "optional digit" in output + + +def test_zero_or_more_digits_reads_as_plural(): + output = _render(ZeroOrMoreElement(child=DigitElement())) + assert "zero or more digits" in output + + +def test_zero_or_more_lazy_marks_lazy(): + output = _render(ZeroOrMoreLazyElement(child=DigitElement())) + assert "zero or more digits (lazy)" in output + + +def test_one_or_more_lazy_marks_lazy(): + output = _render(OneOrMoreLazyElement(child=DigitElement())) + assert "one or more digits (lazy)" in output + + +def test_at_least_reads_as_at_least_n_plural(): + output = _render(AtLeastElement(times=3, child=DigitElement())) + assert "at least 3 digits" in output + + +def test_at_most_reads_as_at_most_n_plural(): + output = _render(AtMostElement(times=2, child=DigitElement())) + assert "at most 2 digits" in output + + +def test_between_reads_as_lower_to_upper_plural(): + output = _render(BetweenElement(lower=2, upper=5, child=DigitElement())) + assert "2 to 5 digits" in output + + +def test_between_lazy_marks_lazy(): + output = _render(BetweenLazyElement(lower=2, upper=5, child=DigitElement())) + assert "2 to 5 digits (lazy)" in output + + +def test_letter_labeled_letter(): + output = _render(LetterElement()) + assert "letter" in output + + +def test_uppercase_labeled_uppercase_letter(): + output = _render(UppercaseElement()) + assert "uppercase letter" in output + + +def test_lowercase_labeled_lowercase_letter(): + output = _render(LowercaseElement()) + assert "lowercase letter" in output + + +def test_alphanumeric_labeled_letter_or_digit(): + output = _render(AlphanumericElement()) + assert "letter or digit" in output + + +def test_any_char_labeled_any_character(): + output = _render(AnyCharElement()) + assert "any character" in output + + +def test_whitespace_labeled_whitespace(): + output = _render(WhitespaceCharElement()) + assert "| whitespace |" in output + + +def test_non_whitespace_labeled_non_whitespace(): + output = _render(NonWhitespaceCharElement()) + assert "non-whitespace" in output + + +def test_non_digit_labeled_non_digit_character(): + output = _render(NonDigitElement()) + assert "non-digit character" in output + + +def test_word_char_labeled_word_character(): + output = _render(WordElement()) + assert "word character" in output + + +def test_non_word_char_labeled_non_word_character(): + output = _render(NonWordElement()) + assert "non-word character" in output + + +def test_word_boundary_labeled_word_boundary(): + output = _render(WordBoundaryElement()) + assert "word boundary" in output + + +def test_non_word_boundary_labeled_non_word_boundary(): + output = _render(NonWordBoundaryElement()) + assert "non-word boundary" in output + + +def test_newline_labeled_newline(): + output = _render(NewLineElement()) + assert "| newline |" in output + + +def test_carriage_return_labeled(): + output = _render(CarriageReturnElement()) + assert "carriage return" in output + + +def test_tab_labeled_tab(): + output = _render(TabElement()) + assert "| tab |" in output + + +def test_null_byte_labeled(): + output = _render(NullByteElement()) + assert "null byte" in output + + +def test_noop_labeled_noop(): + output = _render(NoopElement()) + assert "no-op" in output + + +def test_alternation_with_no_children_shows_nothing_placeholder(): + output = _render(AnyOfElement(children=())) + assert "nothing" in output + + +def test_regex_visualize_end_to_end_delegates_to_render_ascii(): + regex = RegexBuilder().digit().to_regex() + output = regex.visualize() + assert "| digit |" in output + assert "| START |" in output + assert "| END |" in output + + +def test_visualize_with_alternation_end_to_end(): + regex = RegexBuilder().any_of("cat", "dog", "fish").to_regex() + output = regex.visualize() + assert '"cat"' in output + assert '"dog"' in output + assert '"fish"' in output + assert "+--->" in output + + +def test_visualize_named_capture_end_to_end(): + regex = RegexBuilder().named_capture("year").exactly(4).digit().end().to_regex() + output = regex.visualize() + assert '(saved as "year")' in output + assert "4 digits" in output + + +def test_visualize_anchored_pattern_end_to_end(): + regex = RegexBuilder().start_of_input().one_or_more().digit().end_of_input().to_regex() + output = regex.visualize() + assert "text starts here" in output + assert "text ends here" in output + assert "one or more digits" in output + + +def test_singular_stays_singular_for_literal_labels(): + output = _render(ExactlyElement(times=3, child=StringElement(value="ab"))) + assert '3 "ab"' in output + + +def test_pluralize_default_adds_s(): + assert _pluralize("digit") == "digits" + + +def test_pluralize_adds_es_after_ch_ending(): + assert _pluralize("match") == "matches" + + +def test_pluralize_adds_es_after_sh_ending(): + assert _pluralize("fish") == "fishes" + + +def test_pluralize_adds_es_after_x_ending(): + assert _pluralize("box") == "boxes" + + +def test_pluralize_adds_es_after_s_ending(): + assert _pluralize("miss") == "misses" + + +def test_pluralize_adds_es_after_z_ending(): + assert _pluralize("buzz") == "buzzes" + + +def test_pluralize_converts_y_to_ies_after_consonant(): + assert _pluralize("cherry") == "cherries" + + +def test_pluralize_adds_s_after_y_when_preceded_by_vowel(): + assert _pluralize("boy") == "boys" + + +def test_pluralize_leaves_quoted_literal_string_untouched(): + assert _pluralize('"cat"') == '"cat"' + + +def test_optional_of_capture_falls_back_to_group_label(): + inner_capture = CaptureElement(children=(DigitElement(),)) + output = _render(OptionalElement(child=inner_capture)) + assert "optional group" in output + + +def test_quantifier_around_alternation_falls_back_to_group_label(): + inner_alt = AnyOfElement(children=(StringElement(value="a"), StringElement(value="b"))) + output = _render(ExactlyElement(times=2, child=inner_alt)) + assert "2 groups" in output + + +def test_unknown_element_type_renders_placeholder_label(): + class MysteryElement(DigitElement.__mro__[1]): + pass + + output = _render(MysteryElement()) + assert "?MysteryElement" in output + + +def test_empty_group_shows_nothing_placeholder(): + output = _render(GroupElement(children=())) + assert "nothing" in output + + +def test_nested_alternation_pads_wider_branch_with_spaces(): + outer = AnyOfElement( + children=( + AnyOfElement( + children=( + StringElement(value="a"), + StringElement(value="b"), + ) + ), + StringElement(value="verylongword"), + ) + ) + output = _render(outer) + assert '"a"' in output + assert '"b"' in output + assert '"verylongword"' in output + + +def test_single_branch_alternation_looks_like_single_branch(): + output = _render(AnyOfElement(children=(StringElement(value="only-one"),))) + assert '"only-one"' in output + assert "+--->" in output + + +def test_looks_like_single_box_rejects_multi_row_diagram(): + multi = Diagram(rows=("+---+", "|abc|", "+---+", "extra"), entry_row=1, width=5) + assert _looks_like_single_box(multi) is False + + +def test_looks_like_single_box_rejects_asymmetric_borders(): + asymmetric = Diagram(rows=("+---+", "| a |", "+xxx+"), entry_row=1, width=5) + assert _looks_like_single_box(asymmetric) is False + + +def test_looks_like_single_box_rejects_non_box_borders(): + non_box = Diagram(rows=(" ", "| a |", " "), entry_row=1, width=5) + assert _looks_like_single_box(non_box) is False + + +def test_looks_like_single_box_rejects_middle_without_pipes(): + bad_middle = Diagram(rows=("+---+", " a ", "+---+"), entry_row=1, width=5) + assert _looks_like_single_box(bad_middle) is False + + +def test_looks_like_single_box_rejects_border_with_non_dash_interior(): + striped = Diagram(rows=("+-x-+", "| a |", "+-x-+"), entry_row=1, width=5) + assert _looks_like_single_box(striped) is False + + +def test_pad_right_returns_diagram_untouched_when_already_wide_enough(): + original = Diagram(rows=("abcde",), entry_row=0, width=5) + assert _pad_right(original, 3) is original + + +def test_widen_returns_diagram_untouched_when_already_wide_enough(): + original = Diagram(rows=("abcde",), entry_row=0, width=5) + assert _widen(original, 3) is original diff --git a/tests/introspect/explain.test.py b/tests/introspect/explain.test.py new file mode 100644 index 0000000..6444fb2 --- /dev/null +++ b/tests/introspect/explain.test.py @@ -0,0 +1,801 @@ +"""Tests for the plain-English explanation renderer in :mod:`edify.introspect.explain`.""" + +from edify import RegexBuilder +from edify.elements.types.captures import ( + BackReferenceElement, + CaptureElement, + NamedBackReferenceElement, + NamedCaptureElement, +) +from edify.elements.types.chars import ( + AnyOfCharsElement, + AnythingButCharsElement, + AnythingButRangeElement, + AnythingButStringElement, + CharElement, + RangeElement, + StringElement, +) +from edify.elements.types.groups import ( + AnyOfElement, + AssertAheadElement, + AssertBehindElement, + AssertNotAheadElement, + AssertNotBehindElement, + GroupElement, + SubexpressionElement, +) +from edify.elements.types.leaves import ( + AlphanumericElement, + AnyCharElement, + CarriageReturnElement, + DigitElement, + EndOfInputElement, + LetterElement, + LowercaseElement, + NewLineElement, + NonDigitElement, + NonWhitespaceCharElement, + NonWordBoundaryElement, + NonWordElement, + NoopElement, + NullByteElement, + StartOfInputElement, + TabElement, + UppercaseElement, + WhitespaceCharElement, + WordBoundaryElement, + WordElement, +) +from edify.elements.types.quantifiers import ( + AtLeastElement, + AtMostElement, + BetweenElement, + BetweenLazyElement, + ExactlyElement, + OneOrMoreElement, + OneOrMoreLazyElement, + OptionalElement, + ZeroOrMoreElement, + ZeroOrMoreLazyElement, +) +from edify.introspect.explain import ( + _describe_inline, + _describe_inline_children, + _describe_optional_inner, + _describe_plural, + _example_for, + _pick_character_not_in, + _pick_character_outside_range, + _wrap_step, + explain_elements, +) + + +def _explain(*elements) -> str: + return explain_elements(tuple(elements)) + + +def _accepted_examples(output: str) -> list[str]: + if "Text this pattern accepts:" not in output: + return [] + accept_block = output.split("Text this pattern accepts:")[1] + stripped_block = accept_block.strip() + lines = stripped_block.splitlines() + return [line.strip() for line in lines if line.strip()] + + +def test_empty_elements_produces_empty_string_notice(): + assert _explain() == "This pattern is empty and matches an empty string." + + +def test_single_digit_reads_as_must_contain_one_digit(): + output = _explain(DigitElement()) + assert output.startswith("- The text must contain one digit (0-9).") + assert "Text this pattern accepts:" in output + + +def test_start_of_input_switches_lead_in_to_must_start_with(): + output = _explain(StartOfInputElement(), DigitElement()) + assert "The text must start with" in output + + +def test_trailing_end_of_input_alone_is_absorbed_silently(): + output = _explain(DigitElement(), EndOfInputElement()) + assert "The text must contain one digit" in output + assert "very end" not in output + + +def test_trailing_assert_ahead_then_end_becomes_must_end_at_line(): + output = _explain( + DigitElement(), + AssertAheadElement(children=(CharElement(value="/"),)), + EndOfInputElement(), + ) + assert 'The text must end at "/".' in output + + +def test_second_element_uses_then_the_text_must_have(): + output = _explain(DigitElement(), CharElement(value="-")) + assert "Then the text must have" in output + + +def test_optional_element_gets_dedicated_optional_prefix(): + output = _explain(OptionalElement(child=DigitElement())) + assert "Optional:" in output + + +def test_zero_or_more_reads_as_zero_or_more_phrase(): + output = _explain(ZeroOrMoreElement(child=DigitElement())) + assert "zero or more digits" in output + + +def test_zero_or_more_lazy_reads_as_as_few_as_possible(): + output = _explain(ZeroOrMoreLazyElement(child=DigitElement())) + assert "as few as possible" in output + + +def test_one_or_more_reads_as_one_or_more_phrase(): + output = _explain(OneOrMoreElement(child=DigitElement())) + assert "one or more digits" in output + + +def test_one_or_more_lazy_reads_as_as_few_as_possible(): + output = _explain(OneOrMoreLazyElement(child=DigitElement())) + assert "as few as possible" in output + + +def test_exactly_reads_as_exactly_n_phrase(): + output = _explain(ExactlyElement(times=4, child=DigitElement())) + assert "exactly 4 digits" in output + + +def test_at_least_reads_as_at_least_n_phrase(): + output = _explain(AtLeastElement(times=3, child=DigitElement())) + assert "at least 3 digits" in output + + +def test_at_most_reads_as_at_most_n_phrase(): + output = _explain(AtMostElement(times=5, child=DigitElement())) + assert "at most 5 digits" in output + + +def test_between_reads_as_between_lower_and_upper_phrase(): + output = _explain(BetweenElement(lower=2, upper=5, child=DigitElement())) + assert "between 2 and 5 digits" in output + + +def test_between_lazy_reads_as_as_few_as_possible(): + output = _explain(BetweenLazyElement(lower=2, upper=5, child=DigitElement())) + assert "as few as possible" in output + + +def test_word_boundary_gets_a_dedicated_bullet(): + output = _explain(WordBoundaryElement()) + assert "word boundary" in output + + +def test_non_word_boundary_gets_a_dedicated_bullet(): + output = _explain(NonWordBoundaryElement()) + assert "must NOT fall on a word boundary" in output + + +def test_capture_step_describes_children_inline(): + output = _explain(CaptureElement(children=(DigitElement(),))) + assert "one digit" in output + + +def test_named_capture_step_describes_children_inline(): + output = _explain(NamedCaptureElement(name="year", children=(DigitElement(),))) + assert "one digit" in output + + +def test_backreference_step_names_the_group_index(): + output = _explain(BackReferenceElement(index=2)) + assert "group #2" in output + + +def test_named_backreference_step_names_the_group_label(): + output = _explain(NamedBackReferenceElement(name="year")) + assert 'group labeled "year"' in output + + +def test_assert_ahead_reads_as_must_be_followed_by(): + output = _explain(DigitElement(), AssertAheadElement(children=(CharElement(value="/"),))) + assert "must be followed by" in output + + +def test_assert_not_ahead_reads_as_must_not_be_followed_by(): + output = _explain( + DigitElement(), + AssertNotAheadElement(children=(CharElement(value="/"),)), + ) + assert "must NOT be followed by" in output + + +def test_assert_behind_reads_as_just_before_this_point(): + output = _explain(DigitElement(), AssertBehindElement(children=(CharElement(value="/"),))) + assert "Just before this point" in output + + +def test_assert_not_behind_reads_as_just_before_must_not_have_appeared(): + output = _explain( + DigitElement(), + AssertNotBehindElement(children=(CharElement(value="/"),)), + ) + assert "must NOT have appeared" in output + + +def test_group_step_describes_children_inline(): + output = _explain(GroupElement(children=(DigitElement(),))) + assert "one digit" in output + + +def test_alternation_uses_either_or_for_two_alternatives(): + output = _explain( + AnyOfElement(children=(StringElement(value="cat"), StringElement(value="dog"))) + ) + assert 'either "cat" or "dog"' in output + + +def test_alternation_uses_either_a_b_or_c_for_three_alternatives(): + output = _explain( + AnyOfElement( + children=( + StringElement(value="cat"), + StringElement(value="dog"), + StringElement(value="fish"), + ) + ) + ) + assert 'either "cat", "dog", or "fish"' in output + + +def test_alternation_with_single_child_falls_through_to_single_phrase(): + output = _explain(AnyOfElement(children=(StringElement(value="only"),))) + assert '"only"' in output + + +def test_alternation_with_no_children_reads_as_nothing(): + output = _explain(AnyOfElement(children=())) + assert "nothing" in output + + +def test_examples_section_lists_up_to_three_accepted_strings(): + output = _explain(DigitElement()) + strings = _accepted_examples(output) + assert 1 <= len(strings) <= 3 + + +def test_examples_are_unique_across_the_three_seeds(): + output = _explain(DigitElement()) + strings = _accepted_examples(output) + assert len(set(strings)) == len(strings) + + +def test_examples_section_omitted_when_only_empty_strings_are_generated(): + output = _explain(StartOfInputElement(), EndOfInputElement()) + assert "Text this pattern accepts:" not in output + + +def test_subexpression_is_flattened_into_the_step_list(): + output = _explain(SubexpressionElement(children=(DigitElement(), CharElement(value="-")))) + assert output.count("- ") >= 2 + + +def test_char_element_reads_as_quoted_literal(): + output = _explain(CharElement(value="\\-")) + assert '"-"' in output + + +def test_string_element_reads_as_quoted_literal(): + output = _explain(StringElement(value="hi")) + assert '"hi"' in output + + +def test_range_element_describes_from_start_to_end(): + output = _explain(RangeElement(start="a", end="z")) + assert 'from "a" through "z"' in output + + +def test_any_of_chars_describes_from_the_set(): + output = _explain(AnyOfCharsElement(value="abc")) + assert 'from the set "abc"' in output + + +def test_anything_but_chars_describes_not_from_the_set(): + output = _explain(AnythingButCharsElement(value="abc")) + assert 'NOT from the set "abc"' in output + + +def test_anything_but_range_describes_outside_range(): + output = _explain(AnythingButRangeElement(start="a", end="z")) + assert 'outside "a" through "z"' in output + + +def test_anything_but_string_describes_position_for_position_mismatch(): + output = _explain(AnythingButStringElement(value="stop")) + assert "position-for-position" in output + + +def test_any_char_reads_as_any_single_character(): + output = _explain(AnyCharElement()) + assert "any single character" in output + + +def test_whitespace_char_mentions_space_tab_newline(): + output = _explain(WhitespaceCharElement()) + assert "whitespace character" in output + + +def test_non_whitespace_char_reads_as_non_whitespace(): + output = _explain(NonWhitespaceCharElement()) + assert "non-whitespace character" in output + + +def test_non_digit_char_reads_as_non_digit_character(): + output = _explain(NonDigitElement()) + assert "non-digit character" in output + + +def test_word_element_reads_as_letter_digit_or_underscore(): + output = _explain(WordElement()) + assert "one letter, digit, or underscore" in output + + +def test_non_word_element_reads_as_not_letter_digit_or_underscore(): + output = _explain(NonWordElement()) + assert "not a letter, digit, or underscore" in output + + +def test_new_line_element_labeled_as_line_feed(): + output = _explain(NewLineElement()) + assert "line-feed character" in output + + +def test_carriage_return_element_labeled_as_carriage_return(): + output = _explain(CarriageReturnElement()) + assert "carriage-return character" in output + + +def test_tab_element_labeled_as_tab_character(): + output = _explain(TabElement()) + assert "tab character" in output + + +def test_null_byte_element_labeled_as_null_byte(): + output = _explain(NullByteElement()) + assert "null byte" in output + + +def test_letter_element_labeled_a_to_z_case_insensitive(): + output = _explain(LetterElement()) + assert "one letter" in output + + +def test_uppercase_element_labeled_uppercase_range(): + output = _explain(UppercaseElement()) + assert "uppercase letter" in output + + +def test_lowercase_element_labeled_lowercase_range(): + output = _explain(LowercaseElement()) + assert "lowercase letter" in output + + +def test_alphanumeric_element_labeled_letter_or_digit(): + output = _explain(AlphanumericElement()) + assert "letter or digit" in output + + +def test_noop_element_labeled_nothing_inline(): + output = _explain(GroupElement(children=(NoopElement(),))) + assert "nothing" in output + + +def test_regex_explain_end_to_end_via_builder(): + output = RegexBuilder().digit().to_regex().explain() + assert "one digit" in output + + +def test_examples_use_digit_rotation_for_a_pure_digit_pattern(): + output = _explain(DigitElement()) + strings = _accepted_examples(output) + assert all(text.isdigit() for text in strings) + + +def test_examples_for_alternation_pick_a_different_branch_per_seed(): + output = _explain( + AnyOfElement( + children=( + StringElement(value="cat"), + StringElement(value="dog"), + StringElement(value="fish"), + ) + ) + ) + strings = set(_accepted_examples(output)) + assert strings == {"cat", "dog", "fish"} + + +def test_examples_for_optional_include_both_present_and_absent_forms(): + output = _explain(DigitElement(), OptionalElement(child=CharElement(value="x")), DigitElement()) + strings = _accepted_examples(output) + assert any("x" in text for text in strings) + + +def test_examples_for_exactly_produce_that_length_string(): + output = _explain(ExactlyElement(times=4, child=DigitElement())) + strings = _accepted_examples(output) + assert all(len(text) == 4 for text in strings) + + +def test_examples_for_at_least_produce_more_than_lower_bound_length(): + output = _explain(AtLeastElement(times=2, child=DigitElement())) + strings = _accepted_examples(output) + assert all(len(text) >= 2 for text in strings) + + +def test_examples_for_at_most_produce_bounded_length(): + output = _explain(AtMostElement(times=3, child=DigitElement())) + strings = _accepted_examples(output) + assert all(1 <= len(text) <= 3 for text in strings) + + +def test_examples_for_between_stay_within_range(): + output = _explain(BetweenElement(lower=2, upper=4, child=DigitElement())) + strings = _accepted_examples(output) + assert all(2 <= len(text) <= 4 for text in strings) + + +def test_examples_for_between_lazy_stay_within_range(): + output = _explain(BetweenLazyElement(lower=2, upper=4, child=DigitElement())) + strings = _accepted_examples(output) + assert all(2 <= len(text) <= 4 for text in strings) + + +def test_examples_for_backreference_use_a_placeholder(): + output = _explain( + CaptureElement(children=(DigitElement(),)), + BackReferenceElement(index=1), + ) + strings = _accepted_examples(output) + assert all("a" in text for text in strings) + + +def test_examples_for_named_backreference_use_a_placeholder(): + output = _explain( + NamedCaptureElement(name="a", children=(DigitElement(),)), + NamedBackReferenceElement(name="a"), + ) + strings = _accepted_examples(output) + assert all("a" in text for text in strings) + + +def test_examples_for_anything_but_chars_pick_a_char_not_in_set(): + output = _explain(AnythingButCharsElement(value="abc")) + strings = _accepted_examples(output) + for text in strings: + for character in text: + assert character not in "abc" + + +def test_examples_for_anything_but_range_pick_a_char_outside_range(): + output = _explain(AnythingButRangeElement(start="a", end="z")) + strings = _accepted_examples(output) + for text in strings: + for character in text: + assert not ("a" <= character <= "z") + + +def test_examples_for_anything_but_string_have_matching_length(): + output = _explain(AnythingButStringElement(value="stop")) + strings = _accepted_examples(output) + assert all(len(text) == 4 for text in strings) + + +def test_examples_for_anything_but_string_empty_produces_empty_seed(): + output = _explain(AnythingButStringElement(value="")) + assert "Text this pattern accepts:" not in output + + +def test_examples_for_any_of_chars_empty_uses_default_letter(): + output = _explain(AnyOfCharsElement(value="")) + strings = _accepted_examples(output) + assert all(text == "a" for text in strings) + + +def test_examples_for_negative_lookaround_contribute_nothing(): + output = _explain( + DigitElement(), + AssertNotAheadElement(children=(CharElement(value="/"),)), + ) + strings = _accepted_examples(output) + assert all(text.isdigit() for text in strings) + + +def test_pick_character_not_in_uses_bang_when_set_exhausts_alphanumerics(): + everything = "abcdefghijklmnopqrstuvwxyz0123456789" + assert _pick_character_not_in(everything) == "!" + + +def test_pick_character_outside_range_uses_bang_when_range_covers_alphanumerics(): + assert _pick_character_outside_range("0", "z") == "!" + + +def test_step_wrap_returns_empty_prefix_for_empty_description(): + wrapped = _wrap_step(1, "") + assert wrapped.strip() == "1." + + +def test_step_wrap_breaks_long_descriptions_across_continuation_lines(): + long_description = " ".join(["word"] * 40) + wrapped = _wrap_step(1, long_description) + assert "\n" in wrapped + + +def test_optional_inner_wraps_char_literal(): + assert _describe_optional_inner(CharElement(value="\\-")) == '"-"' + + +def test_optional_inner_wraps_string_literal(): + assert _describe_optional_inner(StringElement(value="hi")) == '"hi"' + + +def test_optional_inner_delegates_to_inline_for_leaf_elements(): + assert "one digit" in _describe_optional_inner(DigitElement()) + + +def test_describe_inline_falls_back_to_class_name_for_unknown_type(): + class MysteryElement(DigitElement.__mro__[1]): + pass + + mystery = MysteryElement() + description = _describe_inline(mystery) + assert description == "MysteryElement" + + +def test_describe_plural_falls_back_to_of_inline_for_unknown_type(): + class MysteryElement(DigitElement.__mro__[1]): + pass + + mystery = MysteryElement() + plural = _describe_plural(mystery) + assert plural.startswith("of ") + + +def test_step_wrapper_directly_wraps_first_line_at_width_boundary(): + words = " ".join(["w"] * 100) + wrapped = _wrap_step(1, words) + for line in wrapped.splitlines(): + stripped_line = line.strip() + assert len(line) <= 76 or stripped_line.startswith("w") + + +def _plural(element) -> str: + return _describe_plural(element) + + +def _inline(element) -> str: + return _describe_inline(element) + + +def test_plural_any_char_reads_as_generic_characters(): + assert _plural(AnyCharElement()) == "characters (any character)" + + +def test_plural_whitespace_reads_as_whitespace_characters(): + assert "whitespace characters" in _plural(WhitespaceCharElement()) + + +def test_plural_non_whitespace_reads_as_non_whitespace_characters(): + assert _plural(NonWhitespaceCharElement()) == "non-whitespace characters" + + +def test_plural_non_digit_reads_as_non_digit_characters(): + assert _plural(NonDigitElement()) == "non-digit characters" + + +def test_plural_word_reads_as_letters_digits_underscores(): + assert _plural(WordElement()) == "letters, digits, or underscores" + + +def test_plural_non_word_reads_as_not_letters_digits_or_underscores(): + assert "not letters, digits, or underscores" in _plural(NonWordElement()) + + +def test_plural_new_line_reads_as_line_feed_characters(): + assert "line-feed characters" in _plural(NewLineElement()) + + +def test_plural_carriage_return_reads_as_carriage_return_characters(): + assert "carriage-return characters" in _plural(CarriageReturnElement()) + + +def test_plural_tab_reads_as_tab_characters(): + assert "tab characters" in _plural(TabElement()) + + +def test_plural_null_byte_reads_as_null_bytes(): + assert "null bytes" in _plural(NullByteElement()) + + +def test_plural_letter_reads_as_letters_a_to_z(): + assert _plural(LetterElement()) == "letters (a-z or A-Z)" + + +def test_plural_uppercase_reads_as_uppercase_letters(): + assert "uppercase letters" in _plural(UppercaseElement()) + + +def test_plural_lowercase_reads_as_lowercase_letters(): + assert "lowercase letters" in _plural(LowercaseElement()) + + +def test_plural_alphanumeric_reads_as_letters_or_digits(): + assert "letters or digits" in _plural(AlphanumericElement()) + + +def test_plural_char_literal_reads_as_copies_of_character(): + assert "copies of the character" in _plural(CharElement(value="\\-")) + + +def test_plural_string_literal_reads_as_copies_of_text(): + assert "copies of the text" in _plural(StringElement(value="hi")) + + +def test_plural_range_reads_as_from_through(): + assert 'from "a" through "z"' in _plural(RangeElement(start="a", end="z")) + + +def test_plural_any_of_chars_reads_as_from_the_set(): + assert 'from the set "abc"' in _plural(AnyOfCharsElement(value="abc")) + + +def test_plural_anything_but_chars_reads_as_not_from_the_set(): + assert 'NOT from the set "abc"' in _plural(AnythingButCharsElement(value="abc")) + + +def test_plural_anything_but_range_reads_as_outside_range(): + assert 'outside "a" through "z"' in _plural(AnythingButRangeElement(start="a", end="z")) + + +def test_plural_unknown_element_falls_back_to_of_inline_form(): + class MysteryElement(DigitElement.__mro__[1]): + pass + + mystery = MysteryElement() + plural = _plural(mystery) + assert plural.startswith("of ") + + +def test_inline_start_of_input_reads_as_very_beginning_of_text(): + assert _inline(StartOfInputElement()) == "the very beginning of the text" + + +def test_inline_end_of_input_reads_as_very_end_of_text(): + assert _inline(EndOfInputElement()) == "the very end of the text" + + +def test_inline_word_boundary_reads_as_word_boundary(): + assert _inline(WordBoundaryElement()) == "a word boundary" + + +def test_inline_non_word_boundary_reads_as_non_word_boundary_position(): + assert _inline(NonWordBoundaryElement()) == "a non-word-boundary position" + + +def test_inline_optional_reads_as_an_optional_x(): + element = OptionalElement(child=DigitElement()) + + phrase = _inline(element) + + assert phrase.startswith("an optional ") + + +def test_inline_zero_or_more_reads_as_zero_or_more_x(): + element = ZeroOrMoreElement(child=DigitElement()) + + phrase = _inline(element) + + assert phrase.startswith("zero or more") + + +def test_inline_zero_or_more_lazy_notes_as_few_as_possible(): + assert "as few as possible" in _inline(ZeroOrMoreLazyElement(child=DigitElement())) + + +def test_inline_one_or_more_reads_as_one_or_more_x(): + element = OneOrMoreElement(child=DigitElement()) + + phrase = _inline(element) + + assert phrase.startswith("one or more") + + +def test_inline_one_or_more_lazy_notes_as_few_as_possible(): + assert "as few as possible" in _inline(OneOrMoreLazyElement(child=DigitElement())) + + +def test_inline_exactly_reads_as_exactly_n_x(): + element = ExactlyElement(times=4, child=DigitElement()) + + phrase = _inline(element) + + assert phrase.startswith("exactly 4") + + +def test_inline_at_least_reads_as_at_least_n_x(): + element = AtLeastElement(times=3, child=DigitElement()) + + phrase = _inline(element) + + assert phrase.startswith("at least 3") + + +def test_inline_at_most_reads_as_at_most_n_x(): + element = AtMostElement(times=2, child=DigitElement()) + + phrase = _inline(element) + + assert phrase.startswith("at most 2") + + +def test_inline_between_reads_as_between_lower_and_upper(): + assert "between 2 and 5" in _inline(BetweenElement(lower=2, upper=5, child=DigitElement())) + + +def test_inline_between_lazy_notes_as_few_as_possible(): + assert "as few as possible" in _inline( + BetweenLazyElement(lower=2, upper=5, child=DigitElement()) + ) + + +def test_inline_capture_reads_as_child_captured_marker(): + assert "(captured)" in _inline(CaptureElement(children=(DigitElement(),))) + + +def test_inline_named_capture_includes_the_label(): + assert 'label "year"' in _inline(NamedCaptureElement(name="year", children=(DigitElement(),))) + + +def test_inline_group_reads_as_children_inline(): + assert "one digit" in _inline(GroupElement(children=(DigitElement(),))) + + +def test_inline_subexpression_reads_as_children_inline(): + assert "one digit" in _inline(SubexpressionElement(children=(DigitElement(),))) + + +def test_inline_alternation_reads_as_either_or_phrase(): + assert "either" in _inline( + AnyOfElement(children=(StringElement(value="a"), StringElement(value="b"))) + ) + + +def test_inline_backreference_reads_as_same_text_that_group_captured(): + assert "group #1 captured" in _inline(BackReferenceElement(index=1)) + + +def test_inline_named_backreference_reads_as_that_the_name_group_captured(): + assert 'the "year" group captured' in _inline(NamedBackReferenceElement(name="year")) + + +def test_optional_inner_reads_group_children_inline(): + assert "one digit" in _describe_optional_inner(GroupElement(children=(DigitElement(),))) + + +def test_describe_inline_children_reads_as_nothing_when_empty(): + assert _describe_inline_children(()) == "nothing" + + +def test_describe_inline_children_joins_multiple_phrases_with_then(): + joined = _describe_inline_children((DigitElement(), StringElement(value="X"))) + assert ", then " in joined + + +def test_example_for_unknown_element_returns_empty_string(): + class MysteryElement(DigitElement.__mro__[1]): + pass + + assert _example_for(MysteryElement(), 0) == "" diff --git a/tests/introspect/graphviz.test.py b/tests/introspect/graphviz.test.py new file mode 100644 index 0000000..aad8378 --- /dev/null +++ b/tests/introspect/graphviz.test.py @@ -0,0 +1,355 @@ +"""Tests for the Graphviz DOT / SVG renderer in :mod:`edify.introspect.graphviz`.""" + +import builtins +import importlib +import sys + +import pytest + +from edify import RegexBuilder +from edify.elements.types.captures import ( + BackReferenceElement, + CaptureElement, + NamedBackReferenceElement, + NamedCaptureElement, +) +from edify.elements.types.chars import ( + CharElement, + StringElement, +) +from edify.elements.types.groups import ( + AnyOfElement, + AssertAheadElement, + AssertBehindElement, + AssertNotAheadElement, + AssertNotBehindElement, + GroupElement, + SubexpressionElement, +) +from edify.elements.types.leaves import ( + DigitElement, + EndOfInputElement, + StartOfInputElement, + WordElement, +) +from edify.elements.types.quantifiers import ( + AtLeastElement, + AtMostElement, + BetweenElement, + BetweenLazyElement, + ExactlyElement, + OneOrMoreElement, + OneOrMoreLazyElement, + OptionalElement, + ZeroOrMoreElement, + ZeroOrMoreLazyElement, +) +from edify.errors.introspect import MissingGraphvizDependencyError +from edify.introspect import graphviz as graphviz_module +from edify.introspect.graphviz import ( + _Counter, + _escape_dot, + render_dot, + render_graphviz_svg, +) + + +def _dot(*elements) -> str: + return render_dot(tuple(elements)) + + +def test_empty_pattern_produces_direct_start_to_end_edge(): + output = _dot() + assert "start -> end;" in output + assert "digraph edify_pattern" in output + + +def test_single_digit_pattern_creates_one_labeled_node(): + output = _dot(DigitElement()) + assert 'label="digit"' in output + assert "start -> n_1" in output + assert "n_1 -> end" in output + + +def test_header_configures_left_to_right_layout_and_menlo_font(): + output = _dot(DigitElement()) + assert "rankdir=LR" in output + assert 'fontname="Menlo"' in output + + +def test_start_and_end_terminals_are_circles(): + output = _dot(DigitElement()) + assert 'start [label="START", shape=circle]' in output + assert 'end [label="END", shape=circle]' in output + + +def test_one_or_more_digit_folds_quantifier_as_second_line(): + output = _dot(OneOrMoreElement(child=DigitElement())) + assert 'label="digit\\n(one or more)"' in output + + +def test_one_or_more_lazy_appends_lazy_marker_to_phrase(): + output = _dot(OneOrMoreLazyElement(child=DigitElement())) + assert "one or more (lazy)" in output + + +def test_zero_or_more_reads_as_zero_or_more_phrase(): + output = _dot(ZeroOrMoreElement(child=DigitElement())) + assert "zero or more" in output + + +def test_zero_or_more_lazy_appends_lazy_marker(): + output = _dot(ZeroOrMoreLazyElement(child=DigitElement())) + assert "zero or more (lazy)" in output + + +def test_optional_reads_as_optional_phrase(): + output = _dot(OptionalElement(child=DigitElement())) + assert "optional" in output + + +def test_exactly_reads_as_exactly_n_phrase(): + output = _dot(ExactlyElement(times=4, child=DigitElement())) + assert "exactly 4" in output + + +def test_at_least_reads_as_at_least_n_phrase(): + output = _dot(AtLeastElement(times=3, child=DigitElement())) + assert "at least 3" in output + + +def test_at_most_reads_as_at_most_n_phrase(): + output = _dot(AtMostElement(times=5, child=DigitElement())) + assert "at most 5" in output + + +def test_between_reads_as_lower_to_upper_phrase(): + output = _dot(BetweenElement(lower=2, upper=5, child=DigitElement())) + assert "2 to 5" in output + + +def test_between_lazy_appends_lazy_marker(): + output = _dot(BetweenLazyElement(lower=2, upper=5, child=DigitElement())) + assert "2 to 5 (lazy)" in output + + +def test_alternation_creates_fork_and_merge_junction_points(): + output = _dot( + AnyOfElement( + children=( + StringElement(value="cat"), + StringElement(value="dog"), + StringElement(value="fish"), + ) + ) + ) + assert "shape=point, width=0.08" in output + assert output.count("shape=point") == 2 + assert output.count("fork_") >= 1 + assert output.count("merge_") >= 1 + + +def test_alternation_emits_edge_from_fork_and_to_merge_for_each_branch(): + output = _dot( + AnyOfElement( + children=( + StringElement(value="cat"), + StringElement(value="dog"), + StringElement(value="fish"), + ) + ) + ) + assert output.count("fork_1 ->") == 3 + assert output.count("-> merge_2") == 3 + + +def test_empty_alternation_becomes_nothing_placeholder(): + output = _dot(AnyOfElement(children=())) + assert 'label="nothing"' in output + + +def test_named_capture_wraps_children_in_dashed_cluster(): + output = _dot( + NamedCaptureElement( + name="year", + children=(ExactlyElement(times=4, child=DigitElement()),), + ) + ) + assert 'label="saved as \\"year\\""' in output + assert 'style="dashed,rounded"' in output + assert "subgraph cluster_" in output + + +def test_unnamed_capture_wraps_children_in_captured_cluster(): + output = _dot(CaptureElement(children=(DigitElement(),))) + assert 'label="captured"' in output + + +def test_empty_capture_becomes_captured_empty_placeholder(): + output = _dot(CaptureElement(children=())) + assert "captured (empty)" in output + + +def test_group_wraps_children_in_grouped_cluster(): + output = _dot(GroupElement(children=(DigitElement(),))) + assert 'label="grouped"' in output + + +def test_subexpression_flattens_into_a_plain_sequence(): + output = _dot(SubexpressionElement(children=(DigitElement(), WordElement()))) + assert "cluster_" not in output + assert 'label="digit"' in output + assert 'label="word character"' in output + + +def test_empty_subexpression_becomes_empty_placeholder(): + output = _dot(SubexpressionElement(children=())) + assert "empty subexpression" in output + + +def test_start_of_input_reads_as_text_starts_here(): + output = _dot(StartOfInputElement()) + assert 'label="text starts here"' in output + + +def test_end_of_input_reads_as_text_ends_here(): + output = _dot(EndOfInputElement()) + assert 'label="text ends here"' in output + + +def test_backreference_reads_as_match_same_text_as_group_n(): + output = _dot(BackReferenceElement(index=2)) + assert "match same text as group 2" in output + + +def test_named_backreference_reads_as_match_same_text_as_name(): + output = _dot(NamedBackReferenceElement(name="year")) + assert 'match same text as \\"year\\"' in output + + +def test_assert_ahead_wraps_children_in_must_be_followed_by_cluster(): + output = _dot(AssertAheadElement(children=(DigitElement(),))) + assert '"must be followed by"' in output + + +def test_assert_not_ahead_wraps_children_in_must_not_be_followed_by_cluster(): + output = _dot(AssertNotAheadElement(children=(DigitElement(),))) + assert "must NOT be followed by" in output + + +def test_assert_behind_wraps_children_in_must_be_preceded_by_cluster(): + output = _dot(AssertBehindElement(children=(DigitElement(),))) + assert "must be preceded by" in output + + +def test_assert_not_behind_wraps_children_in_must_not_be_preceded_by_cluster(): + output = _dot(AssertNotBehindElement(children=(DigitElement(),))) + assert "must NOT be preceded by" in output + + +def test_character_literal_display_strips_regex_escaping(): + output = _dot(CharElement(value="\\-")) + assert '"\\"-\\""' in output + + +def test_quantifier_wrapping_complex_child_uses_cluster_not_inline_label(): + inner = AnyOfElement(children=(StringElement(value="a"), StringElement(value="b"))) + output = _dot(OneOrMoreElement(child=inner)) + assert '"one or more"' in output + assert "subgraph cluster_" in output + + +def test_unknown_element_type_produces_question_mark_label(): + class MysteryElement(DigitElement.__mro__[1]): + pass + + output = _dot(MysteryElement()) + assert "?MysteryElement" in output + + +def test_counter_advances_and_produces_unique_ids(): + counter = _Counter() + first = counter.next("n") + second = counter.next("n") + third = counter.next("fork") + assert first == "n_1" + assert second == "n_2" + assert third == "fork_3" + + +def test_escape_dot_escapes_backslashes_and_quotes(): + assert _escape_dot('a"b') == 'a\\"b' + assert _escape_dot("a\\b") == "a\\\\b" + + +def test_escape_dot_preserves_newline_escape_for_two_line_labels(): + assert _escape_dot("digit\\n(one or more)") == "digit\\n(one or more)" + + +def test_render_dot_end_to_end_via_builder(): + dot = RegexBuilder().any_of("cat", "dog", "fish").to_regex() + output = render_dot(dot.elements) + assert 'label="\\"cat\\""' in output + assert 'label="\\"dog\\""' in output + assert 'label="\\"fish\\""' in output + + +def test_render_graphviz_svg_returns_svg_string_when_graphviz_available(): + regex = RegexBuilder().digit().to_regex() + output = render_graphviz_svg(regex.elements) + assert output.startswith("<?xml") or output.startswith("<svg") + assert "</svg>" in output + + +def test_render_graphviz_svg_raises_missing_dependency_when_graphviz_absent(monkeypatch): + monkeypatch.setattr(graphviz_module, "_graphviz_module", None) + regex = RegexBuilder().digit().to_regex() + with pytest.raises(MissingGraphvizDependencyError): + render_graphviz_svg(regex.elements) + + +def test_module_level_import_falls_back_to_none_when_graphviz_missing(monkeypatch): + saved_module = sys.modules.pop("graphviz", None) + real_import = builtins.__import__ + + def blocking_import(name, *args, **kwargs): + if name == "graphviz": + raise ImportError("graphviz missing") + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", blocking_import) + monkeypatch.setitem(sys.modules, "graphviz", None) + reloaded = importlib.reload(graphviz_module) + try: + assert reloaded._graphviz_module is None + finally: + monkeypatch.undo() + if saved_module is not None: + sys.modules["graphviz"] = saved_module + importlib.reload(graphviz_module) + + +def test_nested_capture_inside_alternation_renders_cluster_inside_fork_merge(): + outer = AnyOfElement( + children=( + NamedCaptureElement(name="left", children=(DigitElement(),)), + NamedCaptureElement(name="right", children=(WordElement(),)), + ) + ) + output = _dot(outer) + assert output.count("subgraph cluster_") == 2 + assert '"saved as \\"left\\""' in output + assert '"saved as \\"right\\""' in output + + +def test_lookaround_inside_capture_renders_nested_clusters(): + inner = AssertAheadElement(children=(DigitElement(),)) + output = _dot(CaptureElement(children=(inner,))) + assert '"captured"' in output + assert '"must be followed by"' in output + + +def test_visualize_svg_end_to_end_delegates_to_renderer(): + regex = RegexBuilder().digit().to_regex() + svg = regex.visualize(format="svg", engine="graphviz") + assert "</svg>" in svg diff --git a/tests/introspect/verbose.test.py b/tests/introspect/verbose.test.py new file mode 100644 index 0000000..7af8386 --- /dev/null +++ b/tests/introspect/verbose.test.py @@ -0,0 +1,453 @@ +"""Tests for the re.VERBOSE-compatible renderer in :mod:`edify.introspect.verbose`.""" + +import re + +from edify import RegexBuilder +from edify.elements.types.captures import ( + BackReferenceElement, + CaptureElement, + NamedBackReferenceElement, + NamedCaptureElement, +) +from edify.elements.types.chars import ( + AnyOfCharsElement, + AnythingButCharsElement, + AnythingButRangeElement, + AnythingButStringElement, + CharElement, + RangeElement, + StringElement, +) +from edify.elements.types.groups import ( + AnyOfElement, + AssertAheadElement, + AssertBehindElement, + AssertNotAheadElement, + AssertNotBehindElement, + GroupElement, + SubexpressionElement, +) +from edify.elements.types.leaves import ( + AlphanumericElement, + AnyCharElement, + CarriageReturnElement, + DigitElement, + EndOfInputElement, + LetterElement, + LowercaseElement, + NewLineElement, + NonDigitElement, + NonWhitespaceCharElement, + NonWordBoundaryElement, + NonWordElement, + NoopElement, + NullByteElement, + StartOfInputElement, + TabElement, + UppercaseElement, + WhitespaceCharElement, + WordBoundaryElement, + WordElement, +) +from edify.elements.types.quantifiers import ( + AtLeastElement, + AtMostElement, + BetweenElement, + BetweenLazyElement, + ExactlyElement, + OneOrMoreElement, + OneOrMoreLazyElement, + OptionalElement, + ZeroOrMoreElement, + ZeroOrMoreLazyElement, +) +from edify.introspect import verbose as verbose_module +from edify.introspect.verbose import _render_line, verbose_elements + + +def _verbose(*elements) -> str: + return verbose_elements(tuple(elements)) + + +def _strip_pattern(output: str) -> str: + fragments = [] + for line in output.splitlines(): + without_comment = line.split("#", 1)[0] + code = without_comment.strip() + if code: + fragments.append(code) + return "".join(fragments) + + +def test_empty_elements_produces_empty_string(): + assert _verbose() == "" + + +def test_single_digit_reads_as_backslash_d_with_comment(): + output = _verbose(DigitElement()) + assert output.startswith("\\d") + assert "any digit (0-9)" in output + + +def test_start_of_input_reads_as_caret_with_comment(): + output = _verbose(StartOfInputElement()) + assert output.startswith("^") + assert "start of input" in output + + +def test_end_of_input_reads_as_dollar_with_comment(): + output = _verbose(EndOfInputElement()) + assert output.startswith("$") + assert "end of input" in output + + +def test_any_char_reads_as_dot_with_comment(): + output = _verbose(AnyCharElement()) + assert output.startswith(".") + assert "any single character" in output + + +def test_whitespace_reads_as_backslash_s(): + output = _verbose(WhitespaceCharElement()) + assert output.startswith("\\s") + + +def test_non_whitespace_reads_as_backslash_capital_s(): + output = _verbose(NonWhitespaceCharElement()) + assert output.startswith("\\S") + + +def test_non_digit_reads_as_backslash_capital_d(): + output = _verbose(NonDigitElement()) + assert output.startswith("\\D") + + +def test_word_reads_as_backslash_w(): + output = _verbose(WordElement()) + assert output.startswith("\\w") + + +def test_non_word_reads_as_backslash_capital_w(): + output = _verbose(NonWordElement()) + assert output.startswith("\\W") + + +def test_word_boundary_reads_as_backslash_b(): + output = _verbose(WordBoundaryElement()) + assert output.startswith("\\b") + + +def test_non_word_boundary_reads_as_backslash_capital_b(): + output = _verbose(NonWordBoundaryElement()) + assert output.startswith("\\B") + + +def test_new_line_reads_as_backslash_n(): + output = _verbose(NewLineElement()) + assert output.startswith("\\n") + + +def test_carriage_return_reads_as_backslash_r(): + output = _verbose(CarriageReturnElement()) + assert output.startswith("\\r") + + +def test_tab_reads_as_backslash_t(): + output = _verbose(TabElement()) + assert output.startswith("\\t") + + +def test_null_byte_reads_as_backslash_zero(): + output = _verbose(NullByteElement()) + assert output.startswith("\\0") + + +def test_letter_reads_as_alpha_class(): + output = _verbose(LetterElement()) + assert output.startswith("[a-zA-Z]") + + +def test_uppercase_reads_as_uppercase_class(): + output = _verbose(UppercaseElement()) + assert output.startswith("[A-Z]") + + +def test_lowercase_reads_as_a_z_class(): + output = _verbose(LowercaseElement()) + assert output.startswith("[a-z]") + + +def test_alphanumeric_reads_as_alphanum_class(): + output = _verbose(AlphanumericElement()) + assert output.startswith("[a-zA-Z0-9]") + + +def test_noop_reads_as_no_op_comment_and_empty_pattern(): + output = _verbose(NoopElement()) + assert "no-op" in output + assert _strip_pattern(output) == "" + + +def test_char_literal_shows_raw_value_and_literal_comment(): + output = _verbose(CharElement(value="a")) + assert output.startswith("a") + assert 'literal "a"' in output + + +def test_string_literal_shows_raw_value_and_literal_string_comment(): + output = _verbose(StringElement(value="hello")) + assert output.startswith("hello") + assert 'literal string "hello"' in output + + +def test_range_reads_as_bracketed_range_class(): + output = _verbose(RangeElement(start="a", end="z")) + assert output.startswith("[a-z]") + assert "range a-z" in output + + +def test_any_of_chars_reads_as_bracketed_class(): + output = _verbose(AnyOfCharsElement(value="abc")) + assert output.startswith("[abc]") + + +def test_anything_but_chars_reads_as_negated_bracketed_class(): + output = _verbose(AnythingButCharsElement(value="abc")) + assert output.startswith("[^abc]") + + +def test_anything_but_range_reads_as_negated_range_class(): + output = _verbose(AnythingButRangeElement(start="a", end="z")) + assert output.startswith("[^a-z]") + + +def test_anything_but_string_produces_per_position_negation(): + output = _verbose(AnythingButStringElement(value="ab")) + assert "per-position negation" in output + + +def test_optional_digit_reads_as_question_mark_with_comment(): + output = _verbose(OptionalElement(child=DigitElement())) + assert output.startswith("\\d?") + assert "optional (zero or one)" in output + + +def test_zero_or_more_digit_reads_as_star_greedy(): + output = _verbose(ZeroOrMoreElement(child=DigitElement())) + assert output.startswith("\\d*") + assert "zero or more (greedy)" in output + + +def test_zero_or_more_lazy_reads_as_star_lazy(): + output = _verbose(ZeroOrMoreLazyElement(child=DigitElement())) + assert output.startswith("\\d*?") + assert "zero or more (lazy)" in output + + +def test_one_or_more_digit_reads_as_plus_greedy(): + output = _verbose(OneOrMoreElement(child=DigitElement())) + assert output.startswith("\\d+") + assert "one or more (greedy)" in output + + +def test_one_or_more_lazy_reads_as_plus_lazy(): + output = _verbose(OneOrMoreLazyElement(child=DigitElement())) + assert output.startswith("\\d+?") + + +def test_exactly_reads_as_curly_braces_with_count(): + output = _verbose(ExactlyElement(times=4, child=DigitElement())) + assert output.startswith("\\d{4}") + assert "exactly 4" in output + + +def test_at_least_reads_as_curly_braces_with_open_upper(): + output = _verbose(AtLeastElement(times=3, child=DigitElement())) + assert output.startswith("\\d{3,}") + assert "at least 3" in output + + +def test_at_most_reads_as_curly_braces_with_zero_lower(): + output = _verbose(AtMostElement(times=5, child=DigitElement())) + assert output.startswith("\\d{0,5}") + assert "at most 5" in output + + +def test_between_reads_as_curly_braces_with_lower_upper(): + output = _verbose(BetweenElement(lower=2, upper=5, child=DigitElement())) + assert output.startswith("\\d{2,5}") + assert "between 2 and 5 (greedy)" in output + + +def test_between_lazy_reads_as_curly_braces_with_question_mark(): + output = _verbose(BetweenLazyElement(lower=2, upper=5, child=DigitElement())) + assert output.startswith("\\d{2,5}?") + assert "between 2 and 5 (lazy)" in output + + +def test_quantifier_around_multi_char_string_wraps_child_in_non_capturing_group(): + output = _verbose(OneOrMoreElement(child=StringElement(value="ab"))) + assert output.startswith("(?:ab)+") + + +def test_quantifier_around_single_char_string_does_not_add_grouping(): + output = _verbose(OneOrMoreElement(child=StringElement(value="a"))) + assert output.startswith("a+") + + +def test_quantifier_around_group_uses_multi_line_wrap(): + inner = GroupElement(children=(DigitElement(), WordElement())) + output = _verbose(OneOrMoreElement(child=inner)) + assert "begin group for one or more (greedy)" in output + assert "end group; apply one or more (greedy)" in output + + +def test_non_capturing_group_reads_with_begin_and_end_comments(): + output = _verbose(GroupElement(children=(DigitElement(),))) + assert "begin non-capturing group" in output + assert "end non-capturing group" in output + + +def test_alternation_with_multiple_alternatives_lists_each_with_pipe(): + output = _verbose( + AnyOfElement( + children=( + StringElement(value="cat"), + StringElement(value="dog"), + StringElement(value="fish"), + ) + ) + ) + assert "begin alternation" in output + assert "end alternation" in output + assert "alternative 1" in output + assert "alternative 2" in output + assert "alternative 3" in output + assert output.count(" or") >= 2 + + +def test_alternation_with_no_children_reads_as_empty_alternation(): + output = _verbose(AnyOfElement(children=())) + assert "empty alternation" in output + assert "(?:)" in output + + +def test_subexpression_inlines_its_children_without_extra_scoping(): + output = _verbose(SubexpressionElement(children=(DigitElement(), CharElement(value="-")))) + assert "begin non-capturing group" not in output + lines = [line for line in output.splitlines() if line.strip()] + assert len(lines) == 2 + + +def test_capture_reads_with_begin_captured_group_comment(): + output = _verbose(CaptureElement(children=(DigitElement(),))) + assert "begin captured group" in output + assert "end captured group" in output + + +def test_named_capture_includes_the_name_in_both_comments(): + output = _verbose(NamedCaptureElement(name="year", children=(DigitElement(),))) + assert 'begin group named "year"' in output + assert 'end group named "year"' in output + assert "(?P<year>" in output + + +def test_backreference_reads_as_backslash_index_with_comment(): + output = _verbose(BackReferenceElement(index=1)) + assert output.startswith("\\1") + assert "back-reference to group 1" in output + + +def test_named_backreference_reads_as_named_backref_syntax(): + output = _verbose(NamedBackReferenceElement(name="year")) + assert "(?P=year)" in output + assert 'back-reference to group "year"' in output + + +def test_assert_ahead_reads_as_positive_lookahead(): + output = _verbose(AssertAheadElement(children=(DigitElement(),))) + assert "begin positive lookahead" in output + assert "(?=" in output + + +def test_assert_not_ahead_reads_as_negative_lookahead(): + output = _verbose(AssertNotAheadElement(children=(DigitElement(),))) + assert "begin negative lookahead" in output + assert "(?!" in output + + +def test_assert_behind_reads_as_positive_lookbehind(): + output = _verbose(AssertBehindElement(children=(DigitElement(),))) + assert "begin positive lookbehind" in output + assert "(?<=" in output + + +def test_assert_not_behind_reads_as_negative_lookbehind(): + output = _verbose(AssertNotBehindElement(children=(DigitElement(),))) + assert "begin negative lookbehind" in output + assert "(?<!" in output + + +def test_unrecognized_element_falls_back_to_render_element_with_marker(monkeypatch): + class MysteryElement(DigitElement.__mro__[1]): + pass + + monkeypatch.setattr(verbose_module, "render_element", lambda element: "?mystery") + mystery = MysteryElement() + output = _verbose(mystery) + assert "unrecognized (MysteryElement)" in output + assert "?mystery" in output + + +def test_output_is_a_re_verbose_compatible_pattern_that_compiles(): + output = _verbose( + StartOfInputElement(), + OneOrMoreElement(child=DigitElement()), + EndOfInputElement(), + ) + compiled = re.compile(output, flags=re.VERBOSE) + assert compiled.match("42") is not None + assert compiled.match("abc") is None + + +def test_output_compiles_and_matches_for_alternation_pattern(): + output = _verbose( + AnyOfElement( + children=( + StringElement(value="cat"), + StringElement(value="dog"), + StringElement(value="fish"), + ) + ) + ) + compiled = re.compile(output, flags=re.VERBOSE) + for word in ("cat", "dog", "fish"): + assert compiled.match(word) is not None + assert compiled.match("bird") is None + + +def test_alignment_uses_common_comment_column_across_lines(): + output = _verbose(DigitElement(), CharElement(value="-"), DigitElement()) + hash_columns = [line.index("#") for line in output.splitlines() if "#" in line] + assert len(set(hash_columns)) == 1 + + +def test_line_without_comment_returns_bare_fragment(): + assert _render_line("", "\\d", "") == "\\d" + + +def test_line_with_long_fragment_still_leaves_two_space_gap_before_comment(): + long_fragment = "a" * 30 + output = _render_line("", long_fragment, "note") + assert " # note" in output + + +def test_regex_to_verbose_string_end_to_end_via_builder(): + output = RegexBuilder().digit().to_regex().to_verbose_string() + assert output.startswith("\\d") + + +def test_regex_verbose_output_is_accepted_by_re_verbose_mode(): + output = RegexBuilder().string("hello").to_regex().to_verbose_string() + compiled = re.compile(output, flags=re.VERBOSE) + assert compiled.match("hello") is not None diff --git a/tests/introspect/visualize.test.py b/tests/introspect/visualize.test.py new file mode 100644 index 0000000..a1e2ef5 --- /dev/null +++ b/tests/introspect/visualize.test.py @@ -0,0 +1,92 @@ +"""Tests for the visualize dispatcher in :mod:`edify.introspect.visualize`.""" + +import pytest + +from edify import RegexBuilder +from edify.elements.types.leaves import DigitElement +from edify.errors.introspect import ( + UnsupportedVisualizationEngineError, + UnsupportedVisualizationFormatError, +) +from edify.introspect.visualize import visualize_elements + + +def _digit_regex(): + return RegexBuilder().digit().to_regex() + + +def test_default_format_and_engine_produce_ascii_railroad_output(): + output = visualize_elements((DigitElement(),)) + assert "| START |" in output + assert "| digit |" in output + assert "| END |" in output + + +def test_explicit_ascii_format_and_engine_produce_ascii_railroad_output(): + output = visualize_elements((DigitElement(),), format="ascii", engine="ascii") + assert "| digit |" in output + + +def test_ascii_format_with_wrong_engine_raises_engine_error(): + with pytest.raises(UnsupportedVisualizationEngineError): + visualize_elements((DigitElement(),), format="ascii", engine="graphviz") + + +def test_svg_format_with_graphviz_engine_produces_svg_output(): + regex = _digit_regex() + output = visualize_elements(regex.elements, format="svg", engine="graphviz") + assert "</svg>" in output + + +def test_svg_format_with_wrong_engine_raises_engine_error(): + with pytest.raises(UnsupportedVisualizationEngineError): + visualize_elements((DigitElement(),), format="svg", engine="ascii") + + +def test_unknown_format_raises_format_error(): + with pytest.raises(UnsupportedVisualizationFormatError): + visualize_elements((DigitElement(),), format="pdf") + + +def test_unknown_format_error_names_the_received_format_in_message(): + with pytest.raises(UnsupportedVisualizationFormatError) as excinfo: + visualize_elements((DigitElement(),), format="mermaid") + assert "'mermaid'" in str(excinfo.value) + + +def test_engine_error_names_both_format_and_engine_in_message(): + with pytest.raises(UnsupportedVisualizationEngineError) as excinfo: + visualize_elements((DigitElement(),), format="ascii", engine="mermaid") + message = str(excinfo.value) + assert "'ascii'" in message + assert "'mermaid'" in message + + +def test_regex_visualize_end_to_end_defaults_to_ascii(): + regex = _digit_regex() + output = regex.visualize() + assert "| digit |" in output + + +def test_regex_visualize_svg_end_to_end(): + regex = _digit_regex() + output = regex.visualize(format="svg", engine="graphviz") + assert "</svg>" in output + + +def test_regex_visualize_rejects_unknown_format_end_to_end(): + regex = _digit_regex() + with pytest.raises(UnsupportedVisualizationFormatError): + regex.visualize(format="dot") + + +def test_regex_visualize_rejects_engine_mismatch_end_to_end(): + regex = _digit_regex() + with pytest.raises(UnsupportedVisualizationEngineError): + regex.visualize(format="svg", engine="ascii") + + +def test_empty_elements_still_render_start_and_end(): + output = visualize_elements(()) + assert "START" in output + assert "END" in output diff --git a/tests/result/regex.test.py b/tests/result/regex.test.py index d6f2a64..b6cefbe 100644 --- a/tests/result/regex.test.py +++ b/tests/result/regex.test.py @@ -95,3 +95,32 @@ def test_hash_matches_when_equal(): def test_equality_with_non_regex_returns_not_implemented(): a = Regex("\\d+", re.compile("\\d+")) assert a.__eq__("foo") is NotImplemented + + +def test_pattern_attribute_delegates_via_getattr(digit_regex): + assert digit_regex.pattern == "\\d+" + + +def test_flags_attribute_delegates_via_getattr(digit_regex): + assert isinstance(digit_regex.flags, int) + + +def test_groups_attribute_delegates_via_getattr(): + with_groups = Regex("(\\d)(\\w)", re.compile("(\\d)(\\w)")) + assert with_groups.groups == 2 + + +def test_groupindex_attribute_delegates_via_getattr(): + with_named = Regex( + "(?P<num>\\d)(?P<char>\\w)", + re.compile("(?P<num>\\d)(?P<char>\\w)"), + ) + assert dict(with_named.groupindex) == {"num": 0, "char": 1} or dict(with_named.groupindex) == { + "num": 1, + "char": 2, + } + + +def test_missing_attribute_raises_attribute_error(digit_regex): + with pytest.raises(AttributeError): + _ = digit_regex.no_such_attribute |
