aboutsummaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
authornatsuoto <[email protected]>2026-07-10 16:19:08 +0530
committernatsuoto <[email protected]>2026-07-10 16:19:08 +0530
commit127471e2c2ed56cc7d862d0b960bd0333e2179fc (patch)
tree274c3ab7a0facaa550d6635c1129dc74341b3b36 /tests
parent1ca22160898b7c9bcc945ac8d59d71b72f8a7116 (diff)
downloadedify-127471e2c2ed56cc7d862d0b960bd0333e2179fc.tar.xz
edify-127471e2c2ed56cc7d862d0b960bd0333e2179fc.zip
feat(result): Regex wrapper with AST, delegation, and introspection API (#133, #145, #146, #147, #148)
Diffstat (limited to 'tests')
-rw-r--r--tests/introspect/ascii.test.py561
-rw-r--r--tests/introspect/explain.test.py823
-rw-r--r--tests/introspect/graphviz.test.py359
-rw-r--r--tests/introspect/verbose.test.py459
-rw-r--r--tests/introspect/visualize.test.py92
-rw-r--r--tests/result/regex.test.py29
6 files changed, 2323 insertions, 0 deletions
diff --git a/tests/introspect/ascii.test.py b/tests/introspect/ascii.test.py
new file mode 100644
index 0000000..0ebcc8f
--- /dev/null
+++ b/tests/introspect/ascii.test.py
@@ -0,0 +1,561 @@
+"""Tests for the ASCII railroad-diagram renderer in :mod:`edify.introspect.ascii`."""
+
+from edify import Pattern, 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..c782dfb
--- /dev/null
+++ b/tests/introspect/explain.test.py
@@ -0,0 +1,823 @@
+"""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..516d91b
--- /dev/null
+++ b/tests/introspect/graphviz.test.py
@@ -0,0 +1,359 @@
+"""Tests for the Graphviz DOT / SVG renderer in :mod:`edify.introspect.graphviz`."""
+
+import builtins
+import importlib
+import sys
+
+import pytest
+
+from edify import Pattern, 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..1f339a2
--- /dev/null
+++ b/tests/introspect/verbose.test.py
@@ -0,0 +1,459 @@
+"""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_S():
+ output = _verbose(NonWhitespaceCharElement())
+ assert output.startswith("\\S")
+
+
+def test_non_digit_reads_as_backslash_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_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_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_a_z_A_Z_class():
+ output = _verbose(LetterElement())
+ assert output.startswith("[a-zA-Z]")
+
+
+def test_uppercase_reads_as_A_Z_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_a_z_A_Z_0_9_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