diff --git a/src/myfasthtml/core/matcher.py b/src/myfasthtml/core/matcher.py index c701a16..73b8655 100644 --- a/src/myfasthtml/core/matcher.py +++ b/src/myfasthtml/core/matcher.py @@ -1,44 +1,123 @@ +import re +from dataclasses import dataclass + +from fastcore.basics import NotStr + + class Predicate: + def __init__(self, value): + self.value = value + def validate(self, actual): raise NotImplementedError + + def __str__(self): + return f"{self.__class__.__name__}({self.value})" class StartsWith(Predicate): def __init__(self, value): - self.value = value + super().__init__(value) def validate(self, actual): return actual.startswith(self.value) - - def __str__(self): - return f"StartsWith({self.value})" class Contains(Predicate): def __init__(self, value): - self.value = value + super().__init__(value) def validate(self, actual): return self.value in actual - - def __str__(self): - return f"Contains({self.value})" class DoesNotContain(Predicate): def __init__(self, value): - self.value = value + super().__init__(value) def validate(self, actual): return self.value not in actual + + +@dataclass +class DoNotCheck: + desc: str = None + + +@dataclass +class Empty: + desc: str = None + + +class ErrorOutput: + def __init__(self, path, element, expected): + self.path = path + self.element = element + self.expected = expected + self.output = [] + self.indent = "" + + @staticmethod + def _unconstruct_path_item(item): + if "#" in item: + elt_name, elt_id = item.split("#") + return elt_name, "id", elt_id + elif match := re.match(r'(\w+)\[(class|name)=([^]]+)]', item): + return match.groups() + return item, None, None def __str__(self): - return f"DoesNotContain({self.value})" + self.compute() + + def compute(self): + # 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} ...") + 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 += ")" + 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}" + 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) + else: + self._add_to_output(child) + + self.indent = self.indent[:-2] + self._add_to_output(")") + + def _add_to_output(self, msg): + self.output.append(f"{self.indent}{msg}") def matches(actual, expected, path=""): def print_path(p): - return f"Path '{p}':\n\t" if p else "" + return f"Path: '{p}'\n\t" if p else "" def _type(x): return type(x) @@ -59,19 +138,38 @@ def matches(actual, expected, path=""): else: debug_info = _debug_compare(_actual, _expected) - return f"{print_path(path)}\n{msg} : {debug_info}" + return f"{print_path(path)}{msg} : {debug_info}" def _assert_error(msg, _actual=None, _expected=None): assert False, _error_msg(msg, _actual=_actual, _expected=_expected) + def _get_current_path(elt): + if hasattr(elt, "tag"): + res = f"{elt.tag}" + if "id" in elt.attrs: + res += f"#{elt.attrs['id']}" + elif "name" in elt.attrs: + res += f"[name={elt.attrs['name']}]" + elif "class" in elt.attrs: + res += f"[class={elt.attrs['class']}]" + return res + else: + return elt.__class__.__name__ + if actual is not None and expected is None: _assert_error("Actual is not None while expected is None", _actual=actual) + if isinstance(expected, DoNotCheck): + return True + if actual is None and expected is not None: _assert_error("Actual is None while expected is ", _expected=expected) + # set the path + path += "." + _get_current_path(actual) if path else _get_current_path(actual) + assert _type(actual) == _type(expected) or (hasattr(actual, "tag") and hasattr(expected, "tag")), \ - _assert_error("The types are different: ", _actual=actual, _expected=expected) + _error_msg("The types are different: ", _actual=actual, _expected=expected) if isinstance(expected, (list, tuple)): if len(actual) < len(expected): @@ -82,11 +180,25 @@ def matches(actual, expected, path=""): for actual_child, expected_child in zip(actual, expected): assert matches(actual_child, expected_child, path=path) + elif isinstance(expected, NotStr): + to_compare = actual.s.lstrip('\n').lstrip() + assert to_compare.startswith(expected.s), _error_msg("Notstr values are different: ", + _actual=to_compare, + _expected=expected.s) + elif hasattr(expected, "tag"): + # validate the tags names assert actual.tag == expected.tag, _error_msg("The elements are different: ", _actual=actual.tag, _expected=expected.tag) + # special case when the expected element is empty + if len(expected.children) > 0 and isinstance(expected.children[0], Empty): + assert len(actual.children) == 0, _error_msg("Actual is not empty:", _actual=actual) + assert len(actual.attrs) == 0, _error_msg("Actual is not empty:", _actual=actual) + return True + + # 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:", _actual=actual.attrs) @@ -102,5 +214,17 @@ def matches(actual, expected, path=""): _error_msg(f"The values are different for '{expected_attr}': ", _actual=actual.attrs[expected_attr], _expected=expected.attrs[expected_attr]) + + # compare the children + if len(actual.children) < len(expected.children): + _assert_error("Actual is lesser than expected: ", _actual=actual, _expected=expected) + + for actual_child, expected_child in zip(actual.children, expected.children): + assert matches(actual_child, expected_child, path=path) + + else: + assert actual == expected, _error_msg("The values are different: ", + _actual=actual, + _expected=expected) return True diff --git a/tests/test_matches.py b/tests/test_matches.py new file mode 100644 index 0000000..8afd146 --- /dev/null +++ b/tests/test_matches.py @@ -0,0 +1,171 @@ +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.testclient import MyFT + + +@pytest.mark.parametrize('actual, expected', [ + (None, None), + (123, 123), + (Div(), Div()), + ([Div(), Span()], [Div(), Span()]), + (Div(attr1="value"), Div(attr1="value")), + (Div(attr1="value", attr2="value"), Div(attr1="value")), + (Div(attr1="valueXXX", attr2="value"), Div(attr1=StartsWith("value"))), + (Div(attr1="before value after", attr2="value"), Div(attr1=Contains("value"))), + (Div(attr1="before after", attr2="value"), Div(attr1=DoesNotContain("value"))), + (None, DoNotCheck()), + (123, DoNotCheck()), + (Div(), DoNotCheck()), + ([Div(), Span()], DoNotCheck()), + (NotStr("123456"), NotStr("123")), # for NotStr, only the beginning is checked + (Div(), Div(Empty())), + (Div(123), Div(123)), + (Div(Span(123)), Div(Span(123))), + (Div(Span(123)), Div(DoNotCheck())), +]) +def test_i_can_match(actual, expected): + assert matches(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:"), + (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:"), +]) +def test_i_can_detect_errors(actual, expected, error_message): + with pytest.raises(AssertionError) as exc_info: + matches(actual, expected) + assert error_message in str(exc_info.value) + + +@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"), +]) +def test_i_can_properly_show_path(element, expected_path): + def _construct_test_element(source, tail): + res = MyFT(source.tag, source.attrs) + if source.children: + res.children = [_construct_test_element(child, tail) for child in source.children] + else: + res.children = [tail] + return res + + with pytest.raises(AssertionError) as exc_info: + actual = _construct_test_element(element, "Actual") + expected = _construct_test_element(element, "Expected") + matches(actual, expected) + + assert expected_path in str(exc_info.value) + + +def test_i_can_output_error_path(): + elt = Div() + expected = Div() + path = "div#div_id.div.span[class=span_class].p[name=p_name].div" + error_output = ErrorOutput(path, elt, expected) + error_output.compute() + assert error_output.output == ['(div "id"="div_id" ...', + ' (div ...', + ' (span "class"="span_class" ...', + ' (p "name"="p_name" ...', + ' (div )'] + + +def test_i_can_output_error_attribute(): + elt = Div(attr1="value1", attr2="value2") + expected = elt + path = "" + error_output = ErrorOutput(path, elt, expected) + error_output.compute() + assert error_output.output == ['(div "attr1"="value1" "attr2"="value2")'] + + +def test_i_can_output_error_attribute_missing_1(): + elt = Div(attr2="value2") + expected = Div(attr1="value1", attr2="value2") + path = "" + error_output = ErrorOutput(path, elt, expected) + error_output.compute() + assert error_output.output == ['(div "attr1"="** MISSING **" "attr2"="value2")', + ' ^^^^^^^^^^^^^^^^^^^^^^^ '] + + +def test_i_can_output_error_attribute_missing_2(): + elt = Div(attr1="value1") + expected = Div(attr1="value1", attr2="value2") + path = "" + error_output = ErrorOutput(path, elt, expected) + error_output.compute() + assert error_output.output == ['(div "attr1"="value1" "attr2"="** MISSING **")', + ' ^^^^^^^^^^^^^^^^^^^^^^^'] + + +def test_i_can_output_error_attribute_wrong_value(): + elt = Div(attr1="value3", attr2="value2") + expected = Div(attr1="value1", attr2="value2") + path = "" + error_output = ErrorOutput(path, elt, expected) + error_output.compute() + assert error_output.output == ['(div "attr1"="value3" "attr2"="value2")', + ' ^^^^^^^^^^^^^^^^ '] + + +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 + path = "" + error_output = ErrorOutput(path, elt, expected) + error_output.compute() + assert error_output.output == ['(div "attr1"="value1"', + ' (p "id"="p_id")', + ' (div "id"="child_1")', + ' (div "id"="child_2")', + ')', + ] + + +def test_i_can_output_error_child_element_indicating_sub_children(): + elt = Div(P(id="p_id"), Div(Div(id="child_2"), id="child_1"), attr1="value1") + expected = elt + path = "" + error_output = ErrorOutput(path, elt, expected) + error_output.compute() + assert error_output.output == ['(div "attr1"="value1"', + ' (p "id"="p_id")', + ' (div "id"="child_1" ...)', + ')', + ] diff --git a/tests/tests_matches.py b/tests/tests_matches.py deleted file mode 100644 index e27ed77..0000000 --- a/tests/tests_matches.py +++ /dev/null @@ -1,41 +0,0 @@ -import pytest -from fasthtml.components import * - -from myfasthtml.core.matcher import matches, StartsWith, Contains, DoesNotContain - - -@pytest.mark.parametrize('actual, expected', [ - (Div(), Div()), - (None, None), - ([Div(), Span()], [Div(), Span()]), - (Div(attr1="value"), Div(attr1="value")), - (Div(attr1="value", attr2="value"), Div(attr1="value")), - (Div(attr1="valueXXX", attr2="value"), Div(attr1=StartsWith("value"))), - (Div(attr1="before value after", attr2="value"), Div(attr1=Contains("value"))), - (Div(attr1="before after", attr2="value"), Div(attr1=DoesNotContain("value"))), -]) -def test_i_can_match(actual, expected): - assert matches(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:"), - ([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(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)' is not satisfied:"), - -]) -def test_i_can_detect_errors(actual, expected, error_message): - with pytest.raises(AssertionError) as exc_info: - matches(actual, expected) - assert error_message in str(exc_info.value)