Added Controls testing + documentation
This commit is contained in:
@@ -1,9 +1,12 @@
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, Any
|
||||
|
||||
from fastcore.basics import NotStr
|
||||
from fastcore.xml import FT
|
||||
|
||||
from myfasthtml.core.utils import quoted_str
|
||||
from myfasthtml.core.commands import BaseCommand
|
||||
from myfasthtml.core.utils import quoted_str, snake_to_pascal
|
||||
from myfasthtml.test.testclient import MyFT
|
||||
|
||||
MISSING_ATTR = "** MISSING **"
|
||||
@@ -17,7 +20,18 @@ class Predicate:
|
||||
raise NotImplementedError
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.__class__.__name__}({self.value if self.value is not None else ''})"
|
||||
if self.value is None:
|
||||
str_value = ''
|
||||
elif isinstance(self.value, str):
|
||||
str_value = self.value
|
||||
elif isinstance(self.value, (list, tuple)):
|
||||
if len(self.value) == 1:
|
||||
str_value = self.value[0]
|
||||
else:
|
||||
str_value = str(self.value)
|
||||
else:
|
||||
str_value = str(self.value)
|
||||
return f"{self.__class__.__name__}({str_value})"
|
||||
|
||||
def __repr__(self):
|
||||
return f"{self.__class__.__name__}({self.value if self.value is not None else ''})"
|
||||
@@ -47,20 +61,33 @@ class StartsWith(AttrPredicate):
|
||||
return actual.startswith(self.value)
|
||||
|
||||
|
||||
class Contains(AttrPredicate):
|
||||
class EndsWith(AttrPredicate):
|
||||
def __init__(self, value):
|
||||
super().__init__(value)
|
||||
|
||||
def validate(self, actual):
|
||||
return self.value in actual
|
||||
return actual.endswith(self.value)
|
||||
|
||||
|
||||
class Contains(AttrPredicate):
|
||||
def __init__(self, *value, _word=False):
|
||||
super().__init__(value)
|
||||
self._word = _word
|
||||
|
||||
def validate(self, actual):
|
||||
if self._word:
|
||||
words = actual.split()
|
||||
return all(val in words for val in self.value)
|
||||
else:
|
||||
return all(val in actual for val in self.value)
|
||||
|
||||
|
||||
class DoesNotContain(AttrPredicate):
|
||||
def __init__(self, value):
|
||||
def __init__(self, *value):
|
||||
super().__init__(value)
|
||||
|
||||
def validate(self, actual):
|
||||
return self.value not in actual
|
||||
return all(val not in actual for val in self.value)
|
||||
|
||||
|
||||
class AnyValue(AttrPredicate):
|
||||
@@ -75,6 +102,14 @@ class AnyValue(AttrPredicate):
|
||||
return actual is not None
|
||||
|
||||
|
||||
class Regex(AttrPredicate):
|
||||
def __init__(self, pattern):
|
||||
super().__init__(pattern)
|
||||
|
||||
def validate(self, actual):
|
||||
return re.match(self.value, actual) is not None
|
||||
|
||||
|
||||
class ChildrenPredicate(Predicate):
|
||||
"""
|
||||
Predicate given as a child of an element.
|
||||
@@ -116,23 +151,142 @@ class AttributeForbidden(ChildrenPredicate):
|
||||
return element
|
||||
|
||||
|
||||
class HasHtmx(ChildrenPredicate):
|
||||
def __init__(self, command: BaseCommand = None, **htmx_params):
|
||||
super().__init__(None)
|
||||
self.command = command
|
||||
if command:
|
||||
self.htmx_params = command.get_htmx_params() | htmx_params
|
||||
else:
|
||||
self.htmx_params = htmx_params
|
||||
|
||||
self.htmx_params = {k.replace("hx_", "hx-"): v for k, v in self.htmx_params.items()}
|
||||
|
||||
def validate(self, actual):
|
||||
return all(actual.attrs.get(k) == v for k, v in self.htmx_params.items())
|
||||
|
||||
def to_debug(self, element):
|
||||
for k, v in self.htmx_params.items():
|
||||
element.attrs[k] = v
|
||||
return element
|
||||
|
||||
|
||||
class TestObject:
|
||||
def __init__(self, cls, **kwargs):
|
||||
self.cls = cls
|
||||
self.attrs = kwargs
|
||||
|
||||
|
||||
class TestIcon(TestObject):
|
||||
def __init__(self, name: Optional[str] = '', command=None):
|
||||
super().__init__("div")
|
||||
self.name = snake_to_pascal(name) if (name and name[0].islower()) else name
|
||||
self.children = [
|
||||
TestObject(NotStr, s=Regex(f'<svg name="\\w+-{self.name}'))
|
||||
]
|
||||
if command:
|
||||
self.attrs |= command.get_htmx_params()
|
||||
|
||||
def __str__(self):
|
||||
return f'<div><svg name="{self.name}" .../></div>'
|
||||
|
||||
|
||||
class TestIconNotStr(TestObject):
|
||||
def __init__(self, name: Optional[str] = ''):
|
||||
super().__init__(NotStr)
|
||||
self.name = snake_to_pascal(name) if (name and name[0].islower()) else name
|
||||
self.attrs["s"] = Regex(f'<svg name="\\w+-{self.name}')
|
||||
|
||||
def __str__(self):
|
||||
return f'<svg name="{self.name}" .../>'
|
||||
|
||||
|
||||
class TestCommand(TestObject):
|
||||
def __init__(self, name, **kwargs):
|
||||
super().__init__("Command", **kwargs)
|
||||
self.attrs = {"name": name} | kwargs # name should be first
|
||||
|
||||
|
||||
class TestScript(TestObject):
|
||||
def __init__(self, script):
|
||||
super().__init__("script")
|
||||
self.script = script
|
||||
self.children = [
|
||||
NotStr(self.script),
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class DoNotCheck:
|
||||
desc: str = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class Skip:
|
||||
element: Any
|
||||
desc: str = None
|
||||
|
||||
|
||||
def _get_type(x):
|
||||
if hasattr(x, "tag"):
|
||||
return x.tag
|
||||
if isinstance(x, (TestObject, TestIcon)):
|
||||
return x.cls.__name__ if isinstance(x.cls, type) else str(x.cls)
|
||||
return type(x).__name__
|
||||
|
||||
|
||||
def _get_attr(x, attr):
|
||||
if hasattr(x, "attrs"):
|
||||
return x.attrs.get(attr, MISSING_ATTR)
|
||||
|
||||
if not hasattr(x, attr):
|
||||
return MISSING_ATTR
|
||||
|
||||
return getattr(x, attr, MISSING_ATTR)
|
||||
|
||||
|
||||
def _get_attributes(x):
|
||||
"""Get the attributes dict from an element."""
|
||||
if hasattr(x, "attrs"):
|
||||
return x.attrs
|
||||
return {}
|
||||
|
||||
|
||||
def _get_children(x):
|
||||
"""Get the children list from an element."""
|
||||
if hasattr(x, "children"):
|
||||
return x.children
|
||||
return []
|
||||
|
||||
|
||||
def _str_element(element, expected=None, keep_open=None):
|
||||
# compare to itself if no expected element is provided
|
||||
if expected is None:
|
||||
expected = element
|
||||
|
||||
if hasattr(element, "tag"):
|
||||
# the attributes are compared to the expected element
|
||||
elt_attrs = {attr_name: _get_attr(element, attr_name) for attr_name in
|
||||
[attr_name for attr_name in _get_attributes(expected) 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
|
||||
not_special_children = [c for c in element.children if not isinstance(c, Predicate)]
|
||||
if len(not_special_children) == 0: tag_str += ")"
|
||||
return tag_str
|
||||
|
||||
else:
|
||||
return quoted_str(element)
|
||||
|
||||
|
||||
class ErrorOutput:
|
||||
def __init__(self, path, element, expected):
|
||||
self.path = path
|
||||
@@ -157,14 +311,14 @@ 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 = self._str_element(MyFT(elt_name, {attr_name: attr_value}), keep_open=True)
|
||||
path_str = _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"):
|
||||
# display the tag and its attributes
|
||||
tag_str = self._str_element(self.element, self.expected)
|
||||
tag_str = _str_element(self.element, self.expected)
|
||||
self._add_to_output(tag_str)
|
||||
|
||||
# Try to show where the differences are
|
||||
@@ -187,7 +341,7 @@ class ErrorOutput:
|
||||
|
||||
# display the child
|
||||
element_child = self.element.children[element_index]
|
||||
child_str = self._str_element(element_child, expected_child, keep_open=False)
|
||||
child_str = _str_element(element_child, expected_child, keep_open=False)
|
||||
self._add_to_output(child_str)
|
||||
|
||||
# manage errors (only when the expected is a FT element
|
||||
@@ -203,11 +357,11 @@ class ErrorOutput:
|
||||
self._add_to_output(")")
|
||||
|
||||
elif isinstance(self.expected, TestObject):
|
||||
cls = _mytype(self.element)
|
||||
attrs = {attr_name: _mygetattr(self.element, attr_name) for attr_name in self.expected.attrs}
|
||||
cls = _get_type(self.element)
|
||||
attrs = {attr_name: _get_attr(self.element, attr_name) for attr_name in self.expected.attrs}
|
||||
self._add_to_output(f"({cls} {_str_attrs(attrs)})")
|
||||
# Try to show where the differences are
|
||||
error_str = self._detect_error_2(self.element, self.expected)
|
||||
error_str = self._detect_error(self.element, self.expected)
|
||||
if error_str:
|
||||
self._add_to_output(error_str)
|
||||
|
||||
@@ -221,79 +375,36 @@ class ErrorOutput:
|
||||
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
|
||||
|
||||
if hasattr(element, "tag"):
|
||||
# the attributes are compared to the expected element
|
||||
elt_attrs = {attr_name: element.attrs.get(attr_name, MISSING_ATTR) 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
|
||||
not_special_children = [c for c in element.children if not isinstance(c, Predicate)]
|
||||
if len(not_special_children) == 0: tag_str += ")"
|
||||
return tag_str
|
||||
|
||||
else:
|
||||
return quoted_str(element)
|
||||
|
||||
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_ATTR) 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
|
||||
|
||||
def _detect_error_2(self, element, expected):
|
||||
"""
|
||||
Too lazy to refactor original _detect_error
|
||||
:param element:
|
||||
:param expected:
|
||||
:return:
|
||||
Detect errors between element and expected, returning a visual marker string.
|
||||
Unified version that handles both FT elements and TestObjects.
|
||||
"""
|
||||
# For elements with structure (FT or TestObject)
|
||||
if hasattr(expected, "tag") or isinstance(expected, TestObject):
|
||||
element_cls = _mytype(element)
|
||||
expected_cls = _mytype(expected)
|
||||
str_tag_error = (" " if self._matches(element_cls, expected_cls) else "^") * len(element_cls)
|
||||
element_type = _get_type(element)
|
||||
expected_type = _get_type(expected)
|
||||
type_error = (" " if element_type == expected_type else "^") * len(element_type)
|
||||
|
||||
element_attrs = {attr_name: _mygetattr(element, attr_name) for attr_name in expected.attrs}
|
||||
expected_attrs = {attr_name: _mygetattr(expected, attr_name) for attr_name in expected.attrs}
|
||||
element_attrs = {attr_name: _get_attr(element, attr_name) for attr_name in _get_attributes(expected)}
|
||||
expected_attrs = {attr_name: _get_attr(expected, attr_name) for attr_name in _get_attributes(expected)}
|
||||
attrs_in_error = {attr_name for attr_name, attr_value in element_attrs.items() if
|
||||
not self._matches(attr_value, expected_attrs[attr_name])}
|
||||
str_attrs_error = " ".join(len(f'"{name}"="{value}"') * ("^" if name in attrs_in_error else " ")
|
||||
for name, value in element_attrs.items())
|
||||
if str_attrs_error.strip() or str_tag_error.strip():
|
||||
return f" {str_tag_error} {str_attrs_error}"
|
||||
else:
|
||||
return None
|
||||
|
||||
attrs_error = " ".join(
|
||||
len(f'"{name}"="{value}"') * ("^" if name in attrs_in_error else " ")
|
||||
for name, value in element_attrs.items()
|
||||
)
|
||||
|
||||
if type_error.strip() or attrs_error.strip():
|
||||
return f" {type_error} {attrs_error}"
|
||||
return None
|
||||
|
||||
# For simple values
|
||||
else:
|
||||
if not self._matches(element, expected):
|
||||
return len(str(element)) * "^"
|
||||
|
||||
return None
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _matches(element, expected):
|
||||
@@ -343,39 +454,221 @@ class ErrorComparisonOutput:
|
||||
return "\n".join(output)
|
||||
|
||||
|
||||
def matches(actual, expected, path=""):
|
||||
def print_path(p):
|
||||
return f"Path : '{p}'\n" if p else ""
|
||||
class Matcher:
|
||||
"""
|
||||
Matcher class for comparing actual and expected elements.
|
||||
Provides flexible comparison with support for predicates, nested structures,
|
||||
and detailed error reporting.
|
||||
"""
|
||||
|
||||
def _type(x):
|
||||
return type(x)
|
||||
def __init__(self):
|
||||
self.path = ""
|
||||
|
||||
def _debug(elt):
|
||||
return str(elt) if elt else "None"
|
||||
def matches(self, actual, expected):
|
||||
"""
|
||||
Compare actual and expected elements.
|
||||
|
||||
Args:
|
||||
actual: The actual element to compare
|
||||
expected: The expected element or pattern
|
||||
|
||||
Returns:
|
||||
True if elements match, raises AssertionError otherwise
|
||||
"""
|
||||
if actual is not None and expected is None:
|
||||
self._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:
|
||||
self._assert_error("Actual is None while expected is ", _expected=expected)
|
||||
|
||||
# set the path
|
||||
current_path = self._get_current_path(actual)
|
||||
original_path = self.path
|
||||
self.path = self.path + "." + current_path if self.path else current_path
|
||||
|
||||
try:
|
||||
self._match_elements(actual, expected)
|
||||
finally:
|
||||
# restore the original path for sibling comparisons
|
||||
self.path = original_path
|
||||
|
||||
return True
|
||||
|
||||
def _debug_compare(a, b):
|
||||
actual_out = ErrorOutput(path, a, b)
|
||||
expected_out = ErrorOutput(path, b, b)
|
||||
def _match_elements(self, actual, expected):
|
||||
"""Internal method that performs the actual comparison logic."""
|
||||
if isinstance(expected, TestObject) or hasattr(expected, "tag"):
|
||||
self._match_element(actual, expected)
|
||||
return
|
||||
|
||||
if isinstance(expected, Predicate):
|
||||
assert expected.validate(actual), \
|
||||
self._error_msg(f"The condition '{expected}' is not satisfied.",
|
||||
_actual=actual,
|
||||
_expected=expected)
|
||||
return
|
||||
|
||||
assert _get_type(actual) == _get_type(expected), \
|
||||
self._error_msg("The types are different.", _actual=actual, _expected=expected)
|
||||
|
||||
if isinstance(expected, (list, tuple)):
|
||||
self._match_list(actual, expected)
|
||||
elif isinstance(expected, dict):
|
||||
self._match_dict(actual, expected)
|
||||
elif isinstance(expected, NotStr):
|
||||
self._match_notstr(actual, expected)
|
||||
else:
|
||||
assert actual == expected, self._error_msg("The values are different",
|
||||
_actual=actual,
|
||||
_expected=expected)
|
||||
|
||||
def _match_element(self, actual, expected):
|
||||
"""Match a TestObject or FT element."""
|
||||
# Validate the type/tag
|
||||
assert _get_type(actual) == _get_type(expected), \
|
||||
self._error_msg("The types are different.", _actual=_get_type(actual), _expected=_get_type(expected))
|
||||
|
||||
# Special conditions (ChildrenPredicate)
|
||||
expected_children = _get_children(expected)
|
||||
for predicate in [c for c in expected_children if isinstance(c, ChildrenPredicate)]:
|
||||
assert predicate.validate(actual), \
|
||||
self._error_msg(f"The condition '{predicate}' is not satisfied.",
|
||||
_actual=actual,
|
||||
_expected=predicate.to_debug(expected))
|
||||
|
||||
# Compare the attributes
|
||||
expected_attrs = _get_attributes(expected)
|
||||
for expected_attr, expected_value in expected_attrs.items():
|
||||
actual_value = _get_attr(actual, expected_attr)
|
||||
|
||||
# Check if attribute exists
|
||||
if actual_value == MISSING_ATTR:
|
||||
self._assert_error(f"'{expected_attr}' is not found in Actual. (attributes: {self._str_attrs(actual)})",
|
||||
_actual=actual,
|
||||
_expected=expected)
|
||||
|
||||
# Handle Predicate values
|
||||
if isinstance(expected_value, Predicate):
|
||||
assert expected_value.validate(actual_value), \
|
||||
self._error_msg(f"The condition '{expected_value}' is not satisfied.",
|
||||
_actual=actual,
|
||||
_expected=expected)
|
||||
|
||||
# Handle TestObject recursive matching
|
||||
elif isinstance(expected, TestObject):
|
||||
try:
|
||||
self.matches(actual_value, expected_value)
|
||||
except AssertionError as e:
|
||||
match = re.search(r"Error : (.+?)\n", str(e))
|
||||
if match:
|
||||
self._assert_error(f"{match.group(1)} for '{expected_attr}'.",
|
||||
_actual=actual_value,
|
||||
_expected=expected_value)
|
||||
else:
|
||||
self._assert_error(f"The values are different for '{expected_attr}'.",
|
||||
_actual=actual_value,
|
||||
_expected=expected_value)
|
||||
|
||||
# Handle regular value comparison
|
||||
else:
|
||||
assert actual_value == expected_value, \
|
||||
self._error_msg(f"The values are different for '{expected_attr}'.",
|
||||
_actual=actual,
|
||||
_expected=expected)
|
||||
|
||||
# Compare the children (only if present)
|
||||
if expected_children:
|
||||
# Filter out Predicate children
|
||||
expected_children = [c for c in expected_children if not isinstance(c, Predicate)]
|
||||
actual_children = _get_children(actual)
|
||||
|
||||
if len(actual_children) < len(expected_children):
|
||||
self._assert_error("Actual is lesser than expected.", _actual=actual, _expected=expected)
|
||||
|
||||
actual_child_index, expected_child_index = 0, 0
|
||||
while expected_child_index < len(expected_children):
|
||||
if actual_child_index >= len(actual_children):
|
||||
self._assert_error("Nothing more to skip.", _actual=actual, _expected=expected)
|
||||
|
||||
actual_child = actual_children[actual_child_index]
|
||||
expected_child = expected_children[expected_child_index]
|
||||
|
||||
if isinstance(expected_child, Skip):
|
||||
try:
|
||||
# if this is the element to skip, skip it and continue
|
||||
self._match_element(actual_child, expected_child.element)
|
||||
actual_child_index += 1
|
||||
continue
|
||||
except AssertionError:
|
||||
# otherwise try to match with the following element
|
||||
expected_child_index += 1
|
||||
continue
|
||||
|
||||
assert self.matches(actual_child, expected_child)
|
||||
|
||||
actual_child_index += 1
|
||||
expected_child_index += 1
|
||||
|
||||
def _match_list(self, actual, expected):
|
||||
"""Match list or tuple."""
|
||||
if len(actual) < len(expected):
|
||||
self._assert_error("Actual is smaller than expected: ", _actual=actual, _expected=expected)
|
||||
if len(actual) > len(expected):
|
||||
self._assert_error("Actual is bigger than expected: ", _actual=actual, _expected=expected)
|
||||
|
||||
for actual_child, expected_child in zip(actual, expected):
|
||||
assert self.matches(actual_child, expected_child)
|
||||
|
||||
def _match_dict(self, actual, expected):
|
||||
"""Match dictionary."""
|
||||
if len(actual) < len(expected):
|
||||
self._assert_error("Actual is smaller than expected: ", _actual=actual, _expected=expected)
|
||||
if len(actual) > len(expected):
|
||||
self._assert_error("Actual is bigger than expected: ", _actual=actual, _expected=expected)
|
||||
for k, v in expected.items():
|
||||
assert self.matches(actual[k], v)
|
||||
|
||||
def _match_notstr(self, actual, expected):
|
||||
"""Match NotStr type."""
|
||||
to_compare = _get_attr(actual, "s").lstrip('\n').lstrip()
|
||||
assert to_compare.startswith(expected.s), self._error_msg("Notstr values are different: ",
|
||||
_actual=to_compare,
|
||||
_expected=expected.s)
|
||||
|
||||
def _print_path(self):
|
||||
"""Format the current path for error messages."""
|
||||
return f"Path : '{self.path}'\n" if self.path else ""
|
||||
|
||||
def _debug_compare(self, a, b):
|
||||
"""Generate a comparison debug output."""
|
||||
actual_out = ErrorOutput(self.path, a, b)
|
||||
expected_out = ErrorOutput(self.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(self, msg, _actual=None, _expected=None):
|
||||
"""Generate an error message with debug information."""
|
||||
if _actual is None and _expected is None:
|
||||
debug_info = ""
|
||||
elif _actual is None:
|
||||
debug_info = _debug(_expected)
|
||||
debug_info = self._debug(_expected)
|
||||
elif _expected is None:
|
||||
debug_info = _debug(_actual)
|
||||
debug_info = self._debug(_actual)
|
||||
else:
|
||||
debug_info = _debug_compare(_actual, _expected)
|
||||
debug_info = self._debug_compare(_actual, _expected)
|
||||
|
||||
return f"{print_path(path)}Error : {msg}\n{debug_info}"
|
||||
return f"{self._print_path()}Error : {msg}\n{debug_info}"
|
||||
|
||||
def _assert_error(msg, _actual=None, _expected=None):
|
||||
assert False, _error_msg(msg, _actual=_actual, _expected=_expected)
|
||||
def _assert_error(self, msg, _actual=None, _expected=None):
|
||||
"""Raise an assertion error with formatted message."""
|
||||
assert False, self._error_msg(msg, _actual=_actual, _expected=_expected)
|
||||
|
||||
@staticmethod
|
||||
def _get_current_path(elt):
|
||||
"""Get the path representation of an element."""
|
||||
if hasattr(elt, "tag"):
|
||||
res = f"{elt.tag}"
|
||||
if "id" in elt.attrs:
|
||||
@@ -388,192 +681,127 @@ def matches(actual, expected, path=""):
|
||||
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)
|
||||
@staticmethod
|
||||
def _str_attrs(element):
|
||||
"""Format attributes as a string."""
|
||||
attrs = _get_attributes(element)
|
||||
return " ".join(f'"{attr_name}"="{attr_value}"' for attr_name, attr_value in attrs.items())
|
||||
|
||||
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)
|
||||
|
||||
if isinstance(expected, TestObject):
|
||||
assert _mytype(actual) == _mytype(expected), _error_msg("The types are different: ",
|
||||
_actual=actual,
|
||||
_expected=expected)
|
||||
|
||||
for attr, value in expected.attrs.items():
|
||||
assert hasattr(actual, attr), _error_msg(f"'{attr}' is not found in Actual.",
|
||||
_actual=actual,
|
||||
_expected=expected)
|
||||
try:
|
||||
matches(getattr(actual, attr), value)
|
||||
except AssertionError as e:
|
||||
match = re.search(r"Error : (.+?)\n", str(e))
|
||||
if match:
|
||||
assert False, _error_msg(f"{match.group(1)} for '{attr}':",
|
||||
_actual=getattr(actual, attr),
|
||||
_expected=value)
|
||||
assert False, _error_msg(f"The values are different for '{attr}': ",
|
||||
_actual=getattr(actual, attr),
|
||||
_expected=value)
|
||||
return True
|
||||
|
||||
if isinstance(expected, Predicate):
|
||||
assert expected.validate(actual), \
|
||||
_error_msg(f"The condition '{expected}' is not satisfied.",
|
||||
_actual=actual,
|
||||
_expected=expected)
|
||||
|
||||
assert _type(actual) == _type(expected) or (hasattr(actual, "tag") and hasattr(expected, "tag")), \
|
||||
_error_msg("The types are different: ", _actual=actual, _expected=expected)
|
||||
|
||||
if isinstance(expected, (list, tuple)):
|
||||
if len(actual) < len(expected):
|
||||
_assert_error("Actual is smaller than expected: ", _actual=actual, _expected=expected)
|
||||
if len(actual) > len(expected):
|
||||
_assert_error("Actual is bigger than expected: ", _actual=actual, _expected=expected)
|
||||
|
||||
for actual_child, expected_child in zip(actual, expected):
|
||||
assert matches(actual_child, expected_child, path=path)
|
||||
|
||||
elif isinstance(expected, dict):
|
||||
if len(actual) < len(expected):
|
||||
_assert_error("Actual is smaller than expected: ", _actual=actual, _expected=expected)
|
||||
if len(actual) > len(expected):
|
||||
_assert_error("Actual is bigger than expected: ", _actual=actual, _expected=expected)
|
||||
for k, v in expected.items():
|
||||
assert matches(actual[k], v, path=f"{path}[{k}={v}]")
|
||||
|
||||
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 conditions
|
||||
for predicate in [c for c in expected.children if isinstance(c, ChildrenPredicate)]:
|
||||
assert predicate.validate(actual), \
|
||||
_error_msg(f"The condition '{predicate}' is not satisfied.",
|
||||
_actual=actual,
|
||||
_expected=predicate.to_debug(expected))
|
||||
|
||||
# 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,
|
||||
_expected=expected)
|
||||
|
||||
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,
|
||||
_expected=expected)
|
||||
|
||||
else:
|
||||
assert actual.attrs[expected_attr] == expected.attrs[expected_attr], \
|
||||
_error_msg(f"The values are different for '{expected_attr}': ",
|
||||
_actual=actual,
|
||||
_expected=expected)
|
||||
|
||||
# compare the children
|
||||
expected_children = [c for c in expected.children if not isinstance(c, Predicate)]
|
||||
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
|
||||
@staticmethod
|
||||
def _debug(elt):
|
||||
"""Format an element for debug output."""
|
||||
return _str_element(elt, keep_open=False) if elt else "None"
|
||||
|
||||
|
||||
def matches(actual, expected, path=""):
|
||||
"""
|
||||
Compare actual and expected elements.
|
||||
|
||||
This is a convenience wrapper around the Matcher class.
|
||||
|
||||
Args:
|
||||
actual: The actual element to compare
|
||||
expected: The expected element or pattern
|
||||
path: Optional initial path for error reporting
|
||||
|
||||
Returns:
|
||||
True if elements match, raises AssertionError otherwise
|
||||
"""
|
||||
matcher = Matcher()
|
||||
matcher.path = path
|
||||
return matcher.matches(actual, expected)
|
||||
|
||||
|
||||
def find(ft, expected):
|
||||
res = []
|
||||
"""
|
||||
Find all occurrences of an expected element within a FastHTML tree.
|
||||
|
||||
Args:
|
||||
ft: A FastHTML element or list of elements to search in
|
||||
expected: The element pattern to find
|
||||
|
||||
Returns:
|
||||
List of matching elements
|
||||
|
||||
Raises:
|
||||
AssertionError: If no matching elements are found
|
||||
"""
|
||||
|
||||
def _type(x):
|
||||
return type(x)
|
||||
|
||||
def _same(_ft, _expected):
|
||||
if _ft == _expected:
|
||||
def _elements_match(actual, expected):
|
||||
"""Check if two elements are the same based on tag, attributes, and children."""
|
||||
if isinstance(expected, DoNotCheck):
|
||||
return True
|
||||
|
||||
if _ft.tag != _expected.tag:
|
||||
if isinstance(expected, NotStr):
|
||||
to_compare = _get_attr(actual, "s").lstrip('\n').lstrip()
|
||||
return to_compare.startswith(expected.s)
|
||||
|
||||
if isinstance(actual, NotStr) and _get_type(actual) != _get_type(expected):
|
||||
return False # to manage the unexpected __eq__ behavior of NotStr
|
||||
|
||||
if not isinstance(expected, (TestObject, FT)):
|
||||
return actual == expected
|
||||
|
||||
# Compare tags
|
||||
if _get_type(actual) != _get_type(expected):
|
||||
return False
|
||||
|
||||
for attr in _expected.attrs:
|
||||
if attr not in _ft.attrs or _ft.attrs[attr] != _expected.attrs[attr]:
|
||||
# Compare attributes
|
||||
expected_attrs = _get_attributes(expected)
|
||||
for attr_name, expected_attr_value in expected_attrs.items():
|
||||
actual_attr_value = _get_attr(actual, attr_name)
|
||||
# attribute is missing
|
||||
if actual_attr_value == MISSING_ATTR:
|
||||
return False
|
||||
# manage predicate values
|
||||
if isinstance(expected_attr_value, Predicate):
|
||||
return expected_attr_value.validate(actual_attr_value)
|
||||
# finally compare values
|
||||
return actual_attr_value == expected_attr_value
|
||||
|
||||
for expected_child in _expected.children:
|
||||
for ft_child in _ft.children:
|
||||
if _same(ft_child, expected_child):
|
||||
break
|
||||
else:
|
||||
# Compare children recursively
|
||||
expected_children = _get_children(expected)
|
||||
actual_children = _get_children(actual)
|
||||
|
||||
for expected_child in expected_children:
|
||||
# Check if this expected child exists somewhere in actual children
|
||||
if not any(_elements_match(actual_child, expected_child) for actual_child in actual_children):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def _find(current, current_expected):
|
||||
def _search_tree(current, pattern):
|
||||
"""Recursively search for pattern in the tree rooted at current."""
|
||||
# Check if current element matches
|
||||
matches = []
|
||||
if _elements_match(current, pattern):
|
||||
matches.append(current)
|
||||
|
||||
if _type(current) != _type(current_expected):
|
||||
return []
|
||||
# Recursively search in children, in the case that the pattern also appears in children
|
||||
for child in _get_children(current):
|
||||
matches.extend(_search_tree(child, pattern))
|
||||
|
||||
if not hasattr(current, "tag"):
|
||||
return [current] if current == current_expected else []
|
||||
|
||||
_found = []
|
||||
if _same(current, current_expected):
|
||||
_found.append(current)
|
||||
|
||||
# look at the children
|
||||
for child in current.children:
|
||||
_found.extend(_find(child, current_expected))
|
||||
|
||||
return _found
|
||||
return matches
|
||||
|
||||
ft_as_list = [ft] if not isinstance(ft, (list, tuple, set)) else ft
|
||||
# Normalize input to list
|
||||
elements_to_search = ft if isinstance(ft, (list, tuple, set)) else [ft]
|
||||
|
||||
for current_ft in ft_as_list:
|
||||
found = _find(current_ft, expected)
|
||||
res.extend(found)
|
||||
# Search in all provided elements
|
||||
all_matches = []
|
||||
for element in elements_to_search:
|
||||
all_matches.extend(_search_tree(element, expected))
|
||||
|
||||
if len(res) == 0:
|
||||
# Raise error if nothing found
|
||||
if not all_matches:
|
||||
raise AssertionError(f"No element found for '{expected}'")
|
||||
|
||||
return res
|
||||
return all_matches
|
||||
|
||||
|
||||
def _mytype(x):
|
||||
if hasattr(x, "tag"):
|
||||
return x.tag
|
||||
if isinstance(x, TestObject):
|
||||
return x.cls.__name__ if isinstance(x.cls, type) else str(x.cls)
|
||||
return type(x).__name__
|
||||
|
||||
|
||||
def _mygetattr(x, attr):
|
||||
if hasattr(x, "attrs"):
|
||||
return x.attrs.get(attr, MISSING_ATTR)
|
||||
|
||||
if not hasattr(x, attr):
|
||||
return MISSING_ATTR
|
||||
|
||||
return getattr(x, attr, MISSING_ATTR)
|
||||
def find_one(ft, expected):
|
||||
found = find(ft, expected)
|
||||
assert len(found) == 1, f"Found {len(found)} elements for '{expected}'"
|
||||
return found[0]
|
||||
|
||||
|
||||
def _str_attrs(attrs: dict):
|
||||
|
||||
Reference in New Issue
Block a user