From bad2cab28e84cd6369aa81eaa0ab512536de29cc Mon Sep 17 00:00:00 2001 From: Kodjo Sossouvi Date: Sat, 25 Oct 2025 22:34:00 +0200 Subject: [PATCH] Implemented matches() --- src/myfasthtml/core/matcher.py | 173 ++++++++++++++++++++---- tests/test_matches.py | 233 ++++++++++++++++++++++++++++----- 2 files changed, 344 insertions(+), 62 deletions(-) diff --git a/src/myfasthtml/core/matcher.py b/src/myfasthtml/core/matcher.py index 73b8655..67ce003 100644 --- a/src/myfasthtml/core/matcher.py +++ b/src/myfasthtml/core/matcher.py @@ -3,6 +3,8 @@ from dataclasses import dataclass from fastcore.basics import NotStr +from myfasthtml.core.testclient import MyFT + class Predicate: def __init__(self, value): @@ -13,6 +15,14 @@ class Predicate: def __str__(self): return f"{self.__class__.__name__}({self.value})" + + def __eq__(self, other): + if not isinstance(other, Predicate): + return False + return self.value == other.value + + def __hash__(self): + return hash(self.value) class StartsWith(Predicate): @@ -73,51 +83,152 @@ class ErrorOutput: # first render the path hierarchy for p in self.path.split(".")[:-1]: elt_name, attr_name, attr_value = self._unconstruct_path_item(p) - path_str = f'({elt_name} "{attr_name}"="{attr_value}"' if attr_name else f"({elt_name}" - self._add_to_output(f"{path_str} ...") + path_str = self._str_element(MyFT(elt_name, {attr_name: attr_value}), keep_open=True) + self._add_to_output(f"{path_str}") self.indent += " " # then render the element if hasattr(self.expected, "tag") and hasattr(self.element, "tag"): - # render the attributes - elt_attrs = {attr_name: self.element.attrs.get(attr_name, "** MISSING **") for attr_name in self.expected.attrs} - elt_attrs_str = " ".join(f'"{attr_name}"="{attr_value}"' for attr_name, attr_value in elt_attrs.items()) - tag_str = f"({self.element.tag} {elt_attrs_str}" - if len(self.expected.children) == 0: tag_str += ")" + # display the tag and its attributes + tag_str = self._str_element(self.element, self.expected) self._add_to_output(tag_str) - # display the error in the attributes - attrs_in_error = [attr_name for attr_name, attr_value in elt_attrs.items() if - attr_value != self.expected.attrs[attr_name]] - if attrs_in_error: - elt_attrs_error = " ".join(len(f'"{name}"="{value}"') * ("^" if name in attrs_in_error else " ") - for name, value in elt_attrs.items()) - error_str = f" {len(self.element.tag) * " "} {elt_attrs_error}" + # Try to show where the differences are + error_str = self._detect_error(self.element, self.expected) + if error_str: self._add_to_output(error_str) # render the children if len(self.expected.children) > 0: self.indent += " " element_index = 0 - for child in self.expected.children: - if hasattr(child, "tag"): - child_attr_str = " ".join(f'"{attr_name}"="{attr_value}"' for attr_name, attr_value in child.attrs.items()) - sub_children_indicator = " ..." if len(child.children) > 0 else "" - child_str = f"({child.tag} {child_attr_str}{sub_children_indicator})" - self._add_to_output(child_str) + for expected_child in self.expected.children: + if hasattr(expected_child, "tag"): + if element_index < len(self.element.children): + # display the child + element_child = self.element.children[element_index] + child_str = self._str_element(element_child, expected_child, keep_open=False) + self._add_to_output(child_str) + + # manage errors in children + child_error_str = self._detect_error(element_child, expected_child) + if child_error_str: + self._add_to_output(child_error_str) + element_index += 1 + + else: + # When there are fewer children than expected, we display a placeholder + child_str = "! ** MISSING ** !" + self._add_to_output(child_str) + else: - self._add_to_output(child) + self._add_to_output(expected_child) self.indent = self.indent[:-2] self._add_to_output(")") + else: + self._add_to_output(str(self.element)) + # Try to show where the differences are + error_str = self._detect_error(self.element, self.expected) + if error_str: + self._add_to_output(error_str) def _add_to_output(self, msg): self.output.append(f"{self.indent}{msg}") + + @staticmethod + def _str_element(element, expected=None, keep_open=None): + # compare to itself if no expected element is provided + if expected is None: + expected = element + + # the attributes are compared to the expected element + elt_attrs = {attr_name: element.attrs.get(attr_name, "** MISSING **") for attr_name in + [attr_name for attr_name in expected.attrs if attr_name is not None]} + elt_attrs_str = " ".join(f'"{attr_name}"="{attr_value}"' for attr_name, attr_value in elt_attrs.items()) + + # + tag_str = f"({element.tag} {elt_attrs_str}" + + # manage the closing tag + if keep_open is False: + tag_str += " ...)" if len(element.children) > 0 else ")" + elif keep_open is True: + tag_str += "..." if elt_attrs_str == "" else " ..." + else: + # close the tag if there are no children + if len(element.children) == 0: tag_str += ")" + + return tag_str + + def _detect_error(self, element, expected): + if hasattr(expected, "tag") and hasattr(element, "tag"): + tag_str = len(element.tag) * (" " if element.tag == expected.tag else "^") + elt_attrs = {attr_name: element.attrs.get(attr_name, "** MISSING **") for attr_name in expected.attrs} + attrs_in_error = [attr_name for attr_name, attr_value in elt_attrs.items() if + not self._matches(attr_value, expected.attrs[attr_name])] + if attrs_in_error: + elt_attrs_error = " ".join(len(f'"{name}"="{value}"') * ("^" if name in attrs_in_error else " ") + for name, value in elt_attrs.items()) + error_str = f" {tag_str} {elt_attrs_error}" + return error_str + else: + if not self._matches(element, expected): + return len(str(element)) * "^" + + return None + + @staticmethod + def _matches(element, expected): + if element == expected: + return True + elif isinstance(expected, Predicate): + return expected.validate(element) + else: + return element == expected + + +class ErrorComparisonOutput: + def __init__(self, actual_error_output, expected_error_output): + self.actual_error_output = actual_error_output + self.expected_error_output = expected_error_output + + @staticmethod + def adjust(to_adjust, reference): + for index, ref_line in enumerate(reference): + if "^^" in ref_line: + # insert an empty line in to_adjust + to_adjust.insert(index, "") + + return to_adjust + + def render(self): + # init if needed + if not self.actual_error_output.output: + self.actual_error_output.compute() + if not self.expected_error_output.output: + self.expected_error_output.compute() + + actual = self.actual_error_output.output + expected = self.expected_error_output.output + + # actual = self.adjust(actual, expected) # does not seem to be needed + expected = self.adjust(expected, actual) + + actual_max_length = len(max(actual, key=len)) + # expected_max_length = len(max(expected, key=len)) + + output = [] + for a, e in zip(actual, expected): + line = f"{a:<{actual_max_length}} | {e}".rstrip() + output.append(line) + + return "\n".join(output) def matches(actual, expected, path=""): def print_path(p): - return f"Path: '{p}'\n\t" if p else "" + return f"Path : '{p}'\n" if p else "" def _type(x): return type(x) @@ -126,7 +237,11 @@ def matches(actual, expected, path=""): return str(elt) if elt else "None" def _debug_compare(a, b): - return f"actual={_debug(a)}, expected={_debug(b)}" + actual_out = ErrorOutput(path, a, b) + expected_out = ErrorOutput(path, b, b) + + comparison_out = ErrorComparisonOutput(actual_out, expected_out) + return comparison_out.render() def _error_msg(msg, _actual=None, _expected=None): if _actual is None and _expected is None: @@ -138,7 +253,7 @@ def matches(actual, expected, path=""): else: debug_info = _debug_compare(_actual, _expected) - return f"{print_path(path)}{msg} : {debug_info}" + return f"{print_path(path)}Error : {msg}\n{debug_info}" def _assert_error(msg, _actual=None, _expected=None): assert False, _error_msg(msg, _actual=_actual, _expected=_expected) @@ -188,7 +303,7 @@ def matches(actual, expected, path=""): elif hasattr(expected, "tag"): # validate the tags names - assert actual.tag == expected.tag, _error_msg("The elements are different: ", + assert actual.tag == expected.tag, _error_msg("The elements are different.", _actual=actual.tag, _expected=expected.tag) @@ -200,14 +315,14 @@ def matches(actual, expected, path=""): # compare the attributes for expected_attr, expected_value in expected.attrs.items(): - assert expected_attr in actual.attrs, _error_msg(f"'{expected_attr}' is not found in Actual:", + assert expected_attr in actual.attrs, _error_msg(f"'{expected_attr}' is not found in Actual.", _actual=actual.attrs) if isinstance(expected_value, Predicate): assert expected_value.validate(actual.attrs[expected_attr]), \ - _error_msg(f"The condition '{expected_value}' is not satisfied: ", - _actual=actual.attrs[expected_attr], - _expected=expected_value) + _error_msg(f"The condition '{expected_value}' is not satisfied.", + _actual=actual, + _expected=expected) else: assert actual.attrs[expected_attr] == expected.attrs[expected_attr], \ diff --git a/tests/test_matches.py b/tests/test_matches.py index 8afd146..0dfdc09 100644 --- a/tests/test_matches.py +++ b/tests/test_matches.py @@ -2,7 +2,8 @@ import pytest from fastcore.basics import NotStr from fasthtml.components import * -from myfasthtml.core.matcher import matches, StartsWith, Contains, DoesNotContain, Empty, DoNotCheck, ErrorOutput +from myfasthtml.core.matcher import matches, StartsWith, Contains, DoesNotContain, Empty, DoNotCheck, ErrorOutput, \ + ErrorComparisonOutput from myfasthtml.core.testclient import MyFT @@ -33,31 +34,31 @@ def test_i_can_match(actual, expected): @pytest.mark.parametrize('actual, expected, error_message', [ (None, Div(), "Actual is None"), (Div(), None, "Actual is not None"), - (123, Div(), "The types are different:"), - (123, 124, "The values are different:"), - ([Div(), Span()], [], "Actual is bigger than expected:"), - ([], [Div(), Span()], "Actual is smaller than expected:"), - ("not a list", [Div(), Span()], "The types are different:"), - ([Div(), Span()], [Div(), 123], "The types are different:"), - (Div(), Span(), "The elements are different:"), - ([Div(), Span()], [Div(), Div()], "The elements are different:"), - (Div(), Div(attr1="value"), "'attr1' is not found in Actual:"), - (Div(attr2="value"), Div(attr1="value"), "'attr1' is not found in Actual:"), - (Div(attr1="value1"), Div(attr1="value2"), "The values are different for 'attr1':"), - (Div(attr1="value1"), Div(attr1=StartsWith("value2")), "The condition 'StartsWith(value2)' is not satisfied:"), - (Div(attr1="value1"), Div(attr1=Contains("value2")), "The condition 'Contains(value2)' is not satisfied:"), + (123, Div(), "The types are different"), + (123, 124, "The values are different"), + ([Div(), Span()], [], "Actual is bigger than expected"), + ([], [Div(), Span()], "Actual is smaller than expected"), + ("not a list", [Div(), Span()], "The types are different"), + ([Div(), Span()], [Div(), 123], "The types are different"), + (Div(), Span(), "The elements are different"), + ([Div(), Span()], [Div(), Div()], "The elements are different"), + (Div(), Div(attr1="value"), "'attr1' is not found in Actual"), + (Div(attr2="value"), Div(attr1="value"), "'attr1' is not found in Actual"), + (Div(attr1="value1"), Div(attr1="value2"), "The values are different for 'attr1'"), + (Div(attr1="value1"), Div(attr1=StartsWith("value2")), "The condition 'StartsWith(value2)' is not satisfied"), + (Div(attr1="value1"), Div(attr1=Contains("value2")), "The condition 'Contains(value2)' is not satisfied"), (Div(attr1="value1 value2"), Div(attr1=DoesNotContain("value2")), "The condition 'DoesNotContain(value2)'"), - (NotStr("456"), NotStr("123"), "Notstr values are different:"), - (Div(attr="value"), Div(Empty()), "Actual is not empty:"), - (Div(120), Div(Empty()), "Actual is not empty:"), - (Div(Span()), Div(Empty()), "Actual is not empty:"), - (Div(), Div(Span()), "Actual is lesser than expected:"), - (Div(), Div(123), "Actual is lesser than expected:"), - (Div(Span()), Div(Div()), "The elements are different:"), - (Div(123), Div(Div()), "The types are different:"), - (Div(123), Div(456), "The values are different:"), - (Div(Span(), Span()), Div(Span(), Div()), "The elements are different:"), - (Div(Span(Div())), Div(Span(Span())), "The elements are different:"), + (NotStr("456"), NotStr("123"), "Notstr values are different"), + (Div(attr="value"), Div(Empty()), "Actual is not empty"), + (Div(120), Div(Empty()), "Actual is not empty"), + (Div(Span()), Div(Empty()), "Actual is not empty"), + (Div(), Div(Span()), "Actual is lesser than expected"), + (Div(), Div(123), "Actual is lesser than expected"), + (Div(Span()), Div(Div()), "The elements are different"), + (Div(123), Div(Div()), "The types are different"), + (Div(123), Div(456), "The values are different"), + (Div(Span(), Span()), Div(Span(), Div()), "The elements are different"), + (Div(Span(Div())), Div(Span(Span())), "The elements are different"), ]) def test_i_can_detect_errors(actual, expected, error_message): with pytest.raises(AssertionError) as exc_info: @@ -66,14 +67,14 @@ def test_i_can_detect_errors(actual, expected, error_message): @pytest.mark.parametrize('element, expected_path', [ - (Div(), "Path: 'div"), - (Div(Span()), "Path: 'div.span"), - (Div(Span(Div())), "Path: 'div.span.div"), - (Div(id="div_id"), "Path: 'div#div_id"), - (Div(cls="div_class"), "Path: 'div[class=div_class]"), - (Div(name="div_class"), "Path: 'div[name=div_class]"), - (Div(attr="value"), "Path: 'div"), - (Div(Span(Div(), cls="span_class"), id="div_id"), "Path: 'div#div_id.span[class=span_class].div"), + (Div(), "Path : 'div"), + (Div(Span()), "Path : 'div.span"), + (Div(Span(Div())), "Path : 'div.span.div"), + (Div(id="div_id"), "Path : 'div#div_id"), + (Div(cls="div_class"), "Path : 'div[class=div_class]"), + (Div(name="div_class"), "Path : 'div[name=div_class]"), + (Div(attr="value"), "Path : 'div"), + (Div(Span(Div(), cls="span_class"), id="div_id"), "Path : 'div#div_id.span[class=span_class].div"), ]) def test_i_can_properly_show_path(element, expected_path): def _construct_test_element(source, tail): @@ -144,6 +145,44 @@ def test_i_can_output_error_attribute_wrong_value(): ' ^^^^^^^^^^^^^^^^ '] +def test_i_can_output_error_constant(): + elt = 123 + expected = elt + path = "" + error_output = ErrorOutput(path, elt, expected) + error_output.compute() + assert error_output.output == ['123'] + + +def test_i_can_output_error_constant_wrong_value(): + elt = 123 + expected = 456 + path = "" + error_output = ErrorOutput(path, elt, expected) + error_output.compute() + assert error_output.output == ['123', + '^^^'] + + +def test_i_can_output_error_when_predicate(): + elt = "before value after" + expected = Contains("value") + path = "" + error_output = ErrorOutput(path, elt, expected) + error_output.compute() + assert error_output.output == ["before value after"] + + +def test_i_can_output_error_when_predicate_wrong_value(): + elt = "before after" + expected = Contains("value") + path = "" + error_output = ErrorOutput(path, elt, expected) + error_output.compute() + assert error_output.output == ["before after", + "^^^^^^^^^^^^"] + + def test_i_can_output_error_child_element(): elt = Div(P(id="p_id"), Div(id="child_1"), Div(id="child_2"), attr1="value1") expected = elt @@ -169,3 +208,131 @@ def test_i_can_output_error_child_element_indicating_sub_children(): ' (div "id"="child_1" ...)', ')', ] + + +def test_i_can_output_error_child_element_wrong_value(): + elt = Div(P(id="p_id"), Div(id="child_2"), attr1="value1") + expected = Div(P(id="p_id"), Div(id="child_1"), attr1="value1") + path = "" + error_output = ErrorOutput(path, elt, expected) + error_output.compute() + assert error_output.output == ['(div "attr1"="value1"', + ' (p "id"="p_id")', + ' (div "id"="child_2")', + ' ^^^^^^^^^^^^^^', + ')', + ] + + +def test_i_can_output_error_fewer_elements(): + elt = Div(P(id="p_id"), attr1="value1") + expected = Div(P(id="p_id"), Div(id="child_1"), attr1="value1") + path = "" + error_output = ErrorOutput(path, elt, expected) + error_output.compute() + assert error_output.output == ['(div "attr1"="value1"', + ' (p "id"="p_id")', + ' ! ** MISSING ** !', + ')', + ] + + +def test_i_can_output_comparison(): + actual = Div(P(id="p_id"), attr1="value1") + expected = actual + actual_out = ErrorOutput("", actual, expected) + expected_out = ErrorOutput("", expected, expected) + + comparison_out = ErrorComparisonOutput(actual_out, expected_out) + + res = comparison_out.render() + + assert "\n" + res == ''' +(div "attr1"="value1" | (div "attr1"="value1" + (p "id"="p_id") | (p "id"="p_id") +) | )''' + + +def test_i_can_output_comparison_with_path(): + actual = Div(P(id="p_id"), attr1="value1") + expected = actual + actual_out = ErrorOutput("div#div_id.span[class=cls].div", actual, expected) + expected_out = ErrorOutput("div#div_id.span[class=cls].div", expected, expected) + + comparison_out = ErrorComparisonOutput(actual_out, expected_out) + + res = comparison_out.render() + + assert "\n" + res == ''' +(div "id"="div_id" ... | (div "id"="div_id" ... + (span "class"="cls" ... | (span "class"="cls" ... + (div "attr1"="value1" | (div "attr1"="value1" + (p "id"="p_id") | (p "id"="p_id") + ) | )''' + + +def test_i_can_output_comparison_when_missing_attributes(): + actual = Div(P(id="p_id"), attr1="value1") + expected = Div(P(id="p_id"), attr2="value1") + actual_out = ErrorOutput("", actual, expected) + expected_out = ErrorOutput("", expected, expected) + + comparison_out = ErrorComparisonOutput(actual_out, expected_out) + + res = comparison_out.render() + + assert "\n" + res == ''' +(div "attr2"="** MISSING **" | (div "attr2"="value1" + ^^^^^^^^^^^^^^^^^^^^^^^ | + (p "id"="p_id") | (p "id"="p_id") +) | )''' + + +def test_i_can_output_comparison_when_wrong_attributes(): + actual = Div(P(id="p_id"), attr1="value2") + expected = Div(P(id="p_id"), attr1="value1") + actual_out = ErrorOutput("", actual, expected) + expected_out = ErrorOutput("", expected, expected) + + comparison_out = ErrorComparisonOutput(actual_out, expected_out) + + res = comparison_out.render() + + assert "\n" + res == ''' +(div "attr1"="value2" | (div "attr1"="value1" + ^^^^^^^^^^^^^^^^ | + (p "id"="p_id") | (p "id"="p_id") +) | )''' + + +def test_i_can_output_comparison_when_fewer_elements(): + actual = Div(P(id="p_id"), attr1="value1") + expected = Div(Span(id="s_id"), P(id="p_id"), attr1="value1") + actual_out = ErrorOutput("", actual, expected) + expected_out = ErrorOutput("", expected, expected) + + comparison_out = ErrorComparisonOutput(actual_out, expected_out) + + res = comparison_out.render() + + assert "\n" + res == ''' +(div "attr1"="value1" | (div "attr1"="value1" + (p "id"="p_id") | (span "id"="s_id") + ^ ^^^^^^^^^^^ | + ! ** MISSING ** ! | (p "id"="p_id") +) | )''' + + +def test_i_can_see_the_diff_when_matching(): + actual = Div(attr1="value1") + expected = Div(attr1=Contains("value2")) + + with pytest.raises(AssertionError) as exc_info: + matches(actual, expected) + + debug_output = str(exc_info.value) + assert "\n" + debug_output == """ +Path : 'div' +Error : The condition 'Contains(value2)' is not satisfied. +(div "attr1"="value1") | (div "attr1"="Contains(value2)") + ^^^^^^^^^^^^^^^^ |"""