import re from dataclasses import dataclass from fastcore.basics import NotStr from myfasthtml.core.testclient import MyFT 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})" 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): super().__init__(value) def validate(self, actual): return actual.startswith(self.value) class Contains(Predicate): def __init__(self, value): super().__init__(value) def validate(self, actual): return self.value in actual class DoesNotContain(Predicate): def __init__(self, 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): 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 = 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"): # display the tag and its attributes tag_str = self._str_element(self.element, self.expected) self._add_to_output(tag_str) # 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 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: # 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" if p else "" def _type(x): return type(x) def _debug(elt): return str(elt) if elt else "None" def _debug_compare(a, 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: debug_info = "" elif _actual is None: debug_info = _debug(_expected) elif _expected is None: debug_info = _debug(_actual) else: debug_info = _debug_compare(_actual, _expected) 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) 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")), \ _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, 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) 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.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