Implemented matches()

This commit is contained in:
2025-10-25 22:34:00 +02:00
parent 80215913b6
commit bad2cab28e
2 changed files with 344 additions and 62 deletions

View File

@@ -3,6 +3,8 @@ from dataclasses import dataclass
from fastcore.basics import NotStr from fastcore.basics import NotStr
from myfasthtml.core.testclient import MyFT
class Predicate: class Predicate:
def __init__(self, value): def __init__(self, value):
@@ -13,6 +15,14 @@ class Predicate:
def __str__(self): def __str__(self):
return f"{self.__class__.__name__}({self.value})" 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): class StartsWith(Predicate):
@@ -73,51 +83,152 @@ class ErrorOutput:
# first render the path hierarchy # first render the path hierarchy
for p in self.path.split(".")[:-1]: for p in self.path.split(".")[:-1]:
elt_name, attr_name, attr_value = self._unconstruct_path_item(p) 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}" path_str = self._str_element(MyFT(elt_name, {attr_name: attr_value}), keep_open=True)
self._add_to_output(f"{path_str} ...") self._add_to_output(f"{path_str}")
self.indent += " " self.indent += " "
# then render the element # then render the element
if hasattr(self.expected, "tag") and hasattr(self.element, "tag"): if hasattr(self.expected, "tag") and hasattr(self.element, "tag"):
# render the attributes # display the tag and its attributes
elt_attrs = {attr_name: self.element.attrs.get(attr_name, "** MISSING **") for attr_name in self.expected.attrs} tag_str = self._str_element(self.element, self.expected)
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) self._add_to_output(tag_str)
# display the error in the attributes # Try to show where the differences are
attrs_in_error = [attr_name for attr_name, attr_value in elt_attrs.items() if error_str = self._detect_error(self.element, self.expected)
attr_value != self.expected.attrs[attr_name]] if error_str:
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) self._add_to_output(error_str)
# render the children # render the children
if len(self.expected.children) > 0: if len(self.expected.children) > 0:
self.indent += " " self.indent += " "
element_index = 0 element_index = 0
for child in self.expected.children: for expected_child in self.expected.children:
if hasattr(child, "tag"): if hasattr(expected_child, "tag"):
child_attr_str = " ".join(f'"{attr_name}"="{attr_value}"' for attr_name, attr_value in child.attrs.items()) if element_index < len(self.element.children):
sub_children_indicator = " ..." if len(child.children) > 0 else "" # display the child
child_str = f"({child.tag} {child_attr_str}{sub_children_indicator})" element_child = self.element.children[element_index]
self._add_to_output(child_str) 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: else:
self._add_to_output(child) self._add_to_output(expected_child)
self.indent = self.indent[:-2] self.indent = self.indent[:-2]
self._add_to_output(")") 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): def _add_to_output(self, msg):
self.output.append(f"{self.indent}{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 matches(actual, expected, path=""):
def print_path(p): def print_path(p):
return f"Path: '{p}'\n\t" if p else "" return f"Path : '{p}'\n" if p else ""
def _type(x): def _type(x):
return type(x) return type(x)
@@ -126,7 +237,11 @@ def matches(actual, expected, path=""):
return str(elt) if elt else "None" return str(elt) if elt else "None"
def _debug_compare(a, b): 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): def _error_msg(msg, _actual=None, _expected=None):
if _actual is None and _expected is None: if _actual is None and _expected is None:
@@ -138,7 +253,7 @@ def matches(actual, expected, path=""):
else: else:
debug_info = _debug_compare(_actual, _expected) 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): def _assert_error(msg, _actual=None, _expected=None):
assert False, _error_msg(msg, _actual=_actual, _expected=_expected) assert False, _error_msg(msg, _actual=_actual, _expected=_expected)
@@ -188,7 +303,7 @@ def matches(actual, expected, path=""):
elif hasattr(expected, "tag"): elif hasattr(expected, "tag"):
# validate the tags names # 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, _actual=actual.tag,
_expected=expected.tag) _expected=expected.tag)
@@ -200,14 +315,14 @@ def matches(actual, expected, path=""):
# compare the attributes # compare the attributes
for expected_attr, expected_value in expected.attrs.items(): 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) _actual=actual.attrs)
if isinstance(expected_value, Predicate): if isinstance(expected_value, Predicate):
assert expected_value.validate(actual.attrs[expected_attr]), \ assert expected_value.validate(actual.attrs[expected_attr]), \
_error_msg(f"The condition '{expected_value}' is not satisfied: ", _error_msg(f"The condition '{expected_value}' is not satisfied.",
_actual=actual.attrs[expected_attr], _actual=actual,
_expected=expected_value) _expected=expected)
else: else:
assert actual.attrs[expected_attr] == expected.attrs[expected_attr], \ assert actual.attrs[expected_attr] == expected.attrs[expected_attr], \

View File

@@ -2,7 +2,8 @@ import pytest
from fastcore.basics import NotStr from fastcore.basics import NotStr
from fasthtml.components import * 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 from myfasthtml.core.testclient import MyFT
@@ -33,31 +34,31 @@ def test_i_can_match(actual, expected):
@pytest.mark.parametrize('actual, expected, error_message', [ @pytest.mark.parametrize('actual, expected, error_message', [
(None, Div(), "Actual is None"), (None, Div(), "Actual is None"),
(Div(), None, "Actual is not None"), (Div(), None, "Actual is not None"),
(123, Div(), "The types are different:"), (123, Div(), "The types are different"),
(123, 124, "The values are different:"), (123, 124, "The values are different"),
([Div(), Span()], [], "Actual is bigger than expected:"), ([Div(), Span()], [], "Actual is bigger than expected"),
([], [Div(), Span()], "Actual is smaller than expected:"), ([], [Div(), Span()], "Actual is smaller than expected"),
("not a list", [Div(), Span()], "The types are different:"), ("not a list", [Div(), Span()], "The types are different"),
([Div(), Span()], [Div(), 123], "The types are different:"), ([Div(), Span()], [Div(), 123], "The types are different"),
(Div(), Span(), "The elements are different:"), (Div(), Span(), "The elements are different"),
([Div(), Span()], [Div(), Div()], "The elements are different:"), ([Div(), Span()], [Div(), Div()], "The elements are different"),
(Div(), Div(attr1="value"), "'attr1' is not found in Actual:"), (Div(), Div(attr1="value"), "'attr1' is not found in Actual"),
(Div(attr2="value"), 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="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=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"), Div(attr1=Contains("value2")), "The condition 'Contains(value2)' is not satisfied"),
(Div(attr1="value1 value2"), Div(attr1=DoesNotContain("value2")), "The condition 'DoesNotContain(value2)'"), (Div(attr1="value1 value2"), Div(attr1=DoesNotContain("value2")), "The condition 'DoesNotContain(value2)'"),
(NotStr("456"), NotStr("123"), "Notstr values are different:"), (NotStr("456"), NotStr("123"), "Notstr values are different"),
(Div(attr="value"), Div(Empty()), "Actual is not empty:"), (Div(attr="value"), Div(Empty()), "Actual is not empty"),
(Div(120), Div(Empty()), "Actual is not empty:"), (Div(120), Div(Empty()), "Actual is not empty"),
(Div(Span()), Div(Empty()), "Actual is not empty:"), (Div(Span()), Div(Empty()), "Actual is not empty"),
(Div(), Div(Span()), "Actual is lesser than expected:"), (Div(), Div(Span()), "Actual is lesser than expected"),
(Div(), Div(123), "Actual is lesser than expected:"), (Div(), Div(123), "Actual is lesser than expected"),
(Div(Span()), Div(Div()), "The elements are different:"), (Div(Span()), Div(Div()), "The elements are different"),
(Div(123), Div(Div()), "The types are different:"), (Div(123), Div(Div()), "The types are different"),
(Div(123), Div(456), "The values are different:"), (Div(123), Div(456), "The values are different"),
(Div(Span(), Span()), Div(Span(), Div()), "The elements are different:"), (Div(Span(), Span()), Div(Span(), Div()), "The elements are different"),
(Div(Span(Div())), Div(Span(Span())), "The elements are different:"), (Div(Span(Div())), Div(Span(Span())), "The elements are different"),
]) ])
def test_i_can_detect_errors(actual, expected, error_message): def test_i_can_detect_errors(actual, expected, error_message):
with pytest.raises(AssertionError) as exc_info: 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', [ @pytest.mark.parametrize('element, expected_path', [
(Div(), "Path: 'div"), (Div(), "Path : 'div"),
(Div(Span()), "Path: 'div.span"), (Div(Span()), "Path : 'div.span"),
(Div(Span(Div())), "Path: 'div.span.div"), (Div(Span(Div())), "Path : 'div.span.div"),
(Div(id="div_id"), "Path: 'div#div_id"), (Div(id="div_id"), "Path : 'div#div_id"),
(Div(cls="div_class"), "Path: 'div[class=div_class]"), (Div(cls="div_class"), "Path : 'div[class=div_class]"),
(Div(name="div_class"), "Path: 'div[name=div_class]"), (Div(name="div_class"), "Path : 'div[name=div_class]"),
(Div(attr="value"), "Path: 'div"), (Div(attr="value"), "Path : 'div"),
(Div(Span(Div(), cls="span_class"), id="div_id"), "Path: 'div#div_id.span[class=span_class].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 test_i_can_properly_show_path(element, expected_path):
def _construct_test_element(source, tail): 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(): def test_i_can_output_error_child_element():
elt = Div(P(id="p_id"), Div(id="child_1"), Div(id="child_2"), attr1="value1") elt = Div(P(id="p_id"), Div(id="child_1"), Div(id="child_2"), attr1="value1")
expected = elt expected = elt
@@ -169,3 +208,131 @@ def test_i_can_output_error_child_element_indicating_sub_children():
' (div "id"="child_1" ...)', ' (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)")
^^^^^^^^^^^^^^^^ |"""