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 myfasthtml.core.testclient import MyFT
class Predicate:
def __init__(self, value):
@@ -14,6 +16,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):
def __init__(self, value):
@@ -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})"
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:
self._add_to_output(child)
# 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(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], \

View File

@@ -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:
@@ -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)")
^^^^^^^^^^^^^^^^ |"""