diff options
| author | natsuoto <[email protected]> | 2026-07-10 16:19:08 +0530 |
|---|---|---|
| committer | natsuoto <[email protected]> | 2026-07-10 16:19:08 +0530 |
| commit | 127471e2c2ed56cc7d862d0b960bd0333e2179fc (patch) | |
| tree | 274c3ab7a0facaa550d6635c1129dc74341b3b36 /tests | |
| parent | 1ca22160898b7c9bcc945ac8d59d71b72f8a7116 (diff) | |
| download | edify-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.py | 561 | ||||
| -rw-r--r-- | tests/introspect/explain.test.py | 823 | ||||
| -rw-r--r-- | tests/introspect/graphviz.test.py | 359 | ||||
| -rw-r--r-- | tests/introspect/verbose.test.py | 459 | ||||
| -rw-r--r-- | tests/introspect/visualize.test.py | 92 | ||||
| -rw-r--r-- | tests/result/regex.test.py | 29 |
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 |
