diff --git a/src/myfasthtml/core/matcher.py b/src/myfasthtml/core/matcher.py new file mode 100644 index 0000000..c701a16 --- /dev/null +++ b/src/myfasthtml/core/matcher.py @@ -0,0 +1,106 @@ +class Predicate: + def validate(self, actual): + raise NotImplementedError + + +class StartsWith(Predicate): + def __init__(self, value): + self.value = 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 + + 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 + + def validate(self, actual): + return self.value not in actual + + def __str__(self): + return f"DoesNotContain({self.value})" + + +def matches(actual, expected, path=""): + def print_path(p): + return f"Path '{p}':\n\t" if p else "" + + def _type(x): + return type(x) + + def _debug(elt): + return str(elt) if elt else "None" + + def _debug_compare(a, b): + return f"actual={_debug(a)}, expected={_debug(b)}" + + 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)}\n{msg} : {debug_info}" + + def _assert_error(msg, _actual=None, _expected=None): + assert False, _error_msg(msg, _actual=_actual, _expected=_expected) + + if actual is not None and expected is None: + _assert_error("Actual is not None while expected is None", _actual=actual) + + if actual is None and expected is not None: + _assert_error("Actual is None while expected is ", _expected=expected) + + assert _type(actual) == _type(expected) or (hasattr(actual, "tag") and hasattr(expected, "tag")), \ + _assert_error("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 hasattr(expected, "tag"): + assert actual.tag == expected.tag, _error_msg("The elements are different: ", + _actual=actual.tag, + _expected=expected.tag) + + 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.attrs[expected_attr], + _expected=expected_value) + + 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]) + + return True diff --git a/src/myfasthtml/core/testclient.py b/src/myfasthtml/core/testclient.py index 28696fc..2ab0dc2 100644 --- a/src/myfasthtml/core/testclient.py +++ b/src/myfasthtml/core/testclient.py @@ -1,3 +1,4 @@ +import dataclasses import json import uuid from dataclasses import dataclass @@ -15,6 +16,8 @@ from myfasthtml.core.commands import mount_commands class MyFT: tag: str attrs: dict + children: list['MyFT'] = dataclasses.field(default_factory=list) + text: str | None = None class TestableElement: diff --git a/tests/tests_matches.py b/tests/tests_matches.py new file mode 100644 index 0000000..e27ed77 --- /dev/null +++ b/tests/tests_matches.py @@ -0,0 +1,41 @@ +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)