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)