refactored matcher and wrote readme.txt

This commit is contained in:
2025-11-29 22:53:39 +01:00
parent 1d20fb8650
commit 1be75263ad
3 changed files with 1236 additions and 237 deletions

View File

@@ -0,0 +1,895 @@
# Testing Rendered Components with Matcher
## Introduction
When testing FastHTML components, you need to verify that the HTML they generate is correct. Traditional approaches like string comparison are fragile and hard to maintain. The matcher module provides two powerful functions that make component testing simple and reliable:
- **`matches(actual, expected)`** - Validates that a rendered element matches your expectations
- **`find(ft, expected)`** - Searches for specific elements within an HTML tree
**Key principle**: Test only what matters. The matcher compares only the elements and attributes you explicitly define in your `expected` pattern, ignoring everything else.
### Why use matcher?
**Without matcher:**
```python
# Fragile - breaks if whitespace or attribute order changes
assert str(component.render()) == '<div id="x" class="y"><p>Text</p></div>'
```
**With matcher:**
```python
# Robust - tests only what matters
from myfasthtml.test.matcher import matches
from fasthtml.common import Div, P
actual = component.render()
expected = Div(P("Text"))
matches(actual, expected) # Passes - ignores id and class
```
---
## Part 1: Function Reference
### matches() - Validate Elements
#### Purpose
`matches()` validates that a rendered element structure corresponds exactly to an expected pattern. It's the primary tool for testing component rendering.
**When to use it:**
- Verifying component output in tests
- Checking HTML structure
- Validating attributes and content
#### Basic Syntax
```python
from myfasthtml.test.matcher import matches
matches(actual, expected)
```
- **`actual`**: The element to test (usually from `component.render()`)
- **`expected`**: The pattern to match against (only include what you want to test)
- **Returns**: `True` if matches, raises `AssertionError` if not
#### Simple Examples
**Example 1: Basic structure matching**
```python
from myfasthtml.test.matcher import matches
from fasthtml.common import Div, P
# The actual rendered element
actual = Div(P("Hello World"), id="container", cls="main")
# Expected pattern - tests only the structure
expected = Div(P("Hello World"))
matches(actual, expected) # ✅ Passes - id and cls are ignored
```
**Example 2: Testing specific attributes**
```python
from fasthtml.common import Button
actual = Button("Click me",
id="btn-1",
cls="btn btn-primary",
hx_post="/submit",
hx_target="#result")
# Test only the HTMX attribute we care about
expected = Button("Click me", hx_post="/submit")
matches(actual, expected) # ✅ Passes
```
**Example 3: Nested structure**
```python
from fasthtml.common import Div, H1, Form, Input, Button
actual = Div(
H1("Registration Form"),
Form(
Input(name="email", type="email"),
Input(name="password", type="password"),
Button("Submit", type="submit")
),
id="page",
cls="container"
)
# Test only the important parts
expected = Div(
H1("Registration Form"),
Form(
Input(name="email"),
Button("Submit")
)
)
matches(actual, expected) # ✅ Passes - ignores password field and attributes
```
#### Predicates Reference
Predicates allow flexible validation when you don't know the exact value but want to validate a pattern.
##### AttrPredicate - For attribute values
**Contains(value)** - Attribute contains the value
```python
from myfasthtml.test.matcher import Contains
from fasthtml.common import Div
actual = Div(cls="container main-content active")
expected = Div(cls=Contains("main-content"))
matches(actual, expected) # ✅ Passes
```
**StartsWith(value)** - Attribute starts with the value
```python
from myfasthtml.test.matcher import StartsWith
from fasthtml.common import Input
actual = Input(id="input-username-12345")
expected = Input(id=StartsWith("input-username"))
matches(actual, expected) # ✅ Passes
```
**DoesNotContain(value)** - Attribute does not contain the value
```python
from myfasthtml.test.matcher import DoesNotContain
from fasthtml.common import Div
actual = Div(cls="container active")
expected = Div(cls=DoesNotContain("disabled"))
matches(actual, expected) # ✅ Passes
```
**AnyValue()** - Attribute exists with any non-None value
```python
from myfasthtml.test.matcher import AnyValue
from fasthtml.common import Button
actual = Button("Click", data_action="submit-form", data_id="123")
expected = Button("Click", data_action=AnyValue())
matches(actual, expected) # ✅ Passes - just checks data_action exists
```
##### ChildrenPredicate - For element children
**Empty()** - Element has no children and no attributes
```python
from myfasthtml.test.matcher import Empty
from fasthtml.common import Div
actual = Div()
expected = Div(Empty())
matches(actual, expected) # ✅ Passes
```
**NoChildren()** - Element has no children (but can have attributes)
```python
from myfasthtml.test.matcher import NoChildren
from fasthtml.common import Div
actual = Div(id="container", cls="empty")
expected = Div(NoChildren())
matches(actual, expected) # ✅ Passes - has attributes but no children
```
**AttributeForbidden(attr_name)** - Attribute must not be present
```python
from myfasthtml.test.matcher import AttributeForbidden
from fasthtml.common import Button
actual = Button("Click me")
expected = Button("Click me", AttributeForbidden("disabled"))
matches(actual, expected) # ✅ Passes - disabled attribute is not present
```
#### Error Messages Explained
When a test fails, `matches()` provides a visual diff showing exactly where the problem is:
```python
from fasthtml.common import Div, Button
actual = Div(Button("Submit", cls="btn-primary"), id="form")
expected = Div(Button("Cancel", cls="btn-secondary"))
matches(actual, expected)
```
**Error output:**
```
Path : 'div.button'
Error : The values are different
(div "id"="form" | (div
(button "cls"="btn-prim | (button "cls"="btn-seco
^^^^^^^^^^^^^^^^ |
"Submit") | "Cancel")
^^^^^^^ |
) | )
```
**Reading the error:**
- **Left side**: Actual element
- **Right side**: Expected pattern
- **`^^^` markers**: Highlight differences 'only on the left side', the right side (the expected pattern) is always correct
- **Path**: Shows location in the tree (`div.button` = button inside div)
---
### find() - Search Elements
#### Purpose
`find()` searches for all elements matching a pattern within an HTML tree. It's useful when you need to verify the presence of specific elements without knowing their exact position. Or when you want to validate (using matches) a subset of elements.
**When to use it:**
- Finding elements by attributes
- Verifying element count
- Extracting elements for further validation
- Testing without strict hierarchy requirements
#### Basic Syntax
```python
from myfasthtml.test.matcher import find
results = find(ft, expected)
```
- **`ft`**: Element or list of elements to search in
- **`expected`**: Pattern to match, follows the same syntax and rules as `matches()`
- **Returns**: List of all matching elements
- **Raises**: `AssertionError` if no matches found
#### Simple Examples
**Example 1: Find all elements of a type**
```python
from myfasthtml.test.matcher import find
from fasthtml.common import Div, P
page = Div(
Div(P("First paragraph")),
Div(P("Second paragraph")),
P("Third paragraph")
)
# Find all paragraphs
paragraphs = find(page, P())
assert len(paragraphs) == 3
```
**Example 2: Find by attribute**
```python
from fasthtml.common import Div, Button
from myfasthtml.test.matcher import find
page = Div(
Button("Cancel", cls="btn-secondary"),
Div(Button("Submit", cls="btn-primary", id="submit"))
)
# Find the primary button
primary_buttons = find(page, Button(cls="btn-primary"))
assert len(primary_buttons) == 1
assert primary_buttons[0].attrs["id"] == "submit"
```
**Example 3: Find nested structure**
```python
from fasthtml.common import Div, Form, Input
page = Div(
Div(Input(name="search")),
Form(
Input(name="email", type="email"),
Input(name="password", type="password")
)
)
# Find all email inputs
email_inputs = find(page, Input(type="email"))
assert len(email_inputs) == 1
assert email_inputs[0].attrs["name"] == "email"
```
**Example 4: Search in a list**
```python
from fasthtml.common import Div, P, Span
elements = [
Div(P("First")),
Div(P("Second")),
Span(P("Third"))
]
# Find all paragraphs across all elements
all_paragraphs = find(elements, P())
assert len(all_paragraphs) == 3
```
#### Common Patterns
**Verify element count:**
```python
buttons = find(page, Button())
assert len(buttons) == 3, f"Expected 3 buttons, found {len(buttons)}"
```
**Check element exists:**
```python
submit_buttons = find(page, Button(type="submit"))
assert len(submit_buttons) > 0, "No submit button found"
```
**Extract for further testing:**
```python
form = find(page, Form())[0] # Get first form
inputs = find(form, Input()) # Find inputs within form
assert all(inp.attrs.get("type") in ["text", "email"] for inp in inputs)
```
**Handle missing elements:**
```python
try:
admin_section = find(page, Div(id="admin"))
print("Admin section found")
except AssertionError:
print("Admin section not present")
```
---
## Part 2: Testing Rendered Components Guide
This section provides practical patterns for testing different aspects of rendered components.
### 1. Testing Element Structure
**Goal**: Verify the hierarchy and organization of elements.
**Pattern**: Use `matches()` with only the structural elements you care about.
```python
from myfasthtml.test.matcher import matches
from fasthtml.common import Div, Header, Nav, Main, Footer
# Your component renders a page layout
actual = page_component.render()
# Test only the main structure
expected = Div(
Header(Nav()),
Main(),
Footer()
)
matches(actual, expected)
```
**Tip**: Don't include every single child element. Focus on the important structural elements.
### 2. Testing Attributes
**Goal**: Verify that specific attributes are set correctly.
**Pattern**: Include only the attributes you want to test.
```python
from fasthtml.common import Button
# Component renders a button with HTMX
actual = button_component.render()
# Test only HTMX attributes
expected = Button(
hx_post="/api/submit",
hx_target="#result",
hx_swap="innerHTML"
)
matches(actual, expected)
```
**With predicates for dynamic values:**
```python
from myfasthtml.test.matcher import StartsWith
# Test that id follows a pattern
expected = Button(id=StartsWith("btn-"))
matches(actual, expected)
```
### 3. Testing Content
**Goal**: Verify text content in elements.
**Pattern**: Match elements with their text content.
```python
from fasthtml.common import Div, H1, P
actual = article_component.render()
expected = Div(
H1("Article Title"),
P("First paragraph content")
)
matches(actual, expected)
```
**Partial content matching:**
```python
from myfasthtml.test.matcher import Contains
# Check that paragraph contains key phrase
expected = P(Contains("important information"))
matches(actual, expected)
```
### 4. Testing with Predicates
**Goal**: Validate patterns rather than exact values.
**Pattern**: Use predicates for flexibility.
**Example: Testing generated IDs**
```python
from myfasthtml.test.matcher import StartsWith, AnyValue
from fasthtml.common import Div
# Component generates unique IDs
actual = widget_component.render()
expected = Div(
id=StartsWith("widget-"),
data_timestamp=AnyValue() # Just check it exists
)
matches(actual, expected)
```
**Example: Testing CSS classes**
```python
from myfasthtml.test.matcher import Contains
from fasthtml.common import Button
actual = dynamic_button.render()
# Check button has 'active' class among others
expected = Button(cls=Contains("active"))
matches(actual, expected)
```
**Example: Forbidden attributes**
```python
from myfasthtml.test.matcher import AttributeForbidden
from fasthtml.common import Input
# Verify input is NOT disabled
actual = input_component.render()
expected = Input(
name="username",
AttributeForbidden("disabled")
)
matches(actual, expected)
```
### 5. Combining matches() and find()
**Goal**: First find elements, then validate them in detail.
**Pattern**: Use `find()` to locate, then `matches()` to validate.
**Example: Testing a form**
```python
from myfasthtml.test.matcher import find, matches
from fasthtml.common import Form, Input, Button
# Render a complex page
actual = page_component.render()
# Step 1: Find the registration form
forms = find(actual, Form(id="registration"))
assert len(forms) == 1
# Step 2: Validate the form structure
registration_form = forms[0]
expected_form = Form(
Input(name="email", type="email"),
Input(name="password", type="password"),
Button("Register", type="submit")
)
matches(registration_form, expected_form)
```
**Example: Testing multiple similar elements**
```python
from fasthtml.common import Div, Card
actual = dashboard.render()
# Find all cards
cards = find(actual, Card())
# Verify we have the right number
assert len(cards) == 3
# Validate each card has required structure
for card in cards:
expected_card = Card(
Div(cls="card-header"),
Div(cls="card-body")
)
matches(card, expected_card)
```
### 6. Testing Edge Cases
**Testing empty elements:**
```python
from myfasthtml.test.matcher import Empty, NoChildren
from fasthtml.common import Div
# Test completely empty element
actual = Div()
expected = Div(Empty())
matches(actual, expected)
# Test element with attributes but no children
actual = Div(id="container", cls="empty-state")
expected = Div(NoChildren())
matches(actual, expected)
```
**Testing absence of elements:**
```python
from myfasthtml.test.matcher import find
from fasthtml.common import Div
actual = basic_view.render()
# Verify admin section is not present
try:
find(actual, Div(id="admin-section"))
assert False, "Admin section should not be present"
except AssertionError as e:
if "No element found" in str(e):
pass # Expected - admin section absent
else:
raise
```
---
## Part 3: How It Works (Technical Overview)
Understanding how the matcher works helps you write better tests and debug failures more effectively.
### The Matching Algorithm
When you call `matches(actual, expected)`, here's what happens:
1. **Type Comparison**: Checks if elements have the same type/tag
- For FastHTML elements: compares `.tag` (e.g., "div", "button")
- For Python objects: compares class types
2. **Attribute Validation**: For each attribute in `expected`:
- Checks if attribute exists in `actual`
- If it's a `Predicate`: calls `.validate(actual_value)`
- Otherwise: checks for exact equality
- **Key point**: Only attributes in `expected` are checked
3. **Children Validation**:
- Applies `ChildrenPredicate` validators if present
- Recursively matches children in order
- **Key point**: Only checks as many children as defined in `expected`
4. **Path Tracking**: Maintains a path through the tree for error reporting
### Understanding the Path
The matcher builds a path as it traverses your element tree:
```
div#container.form.input[name=email]
```
This path means:
- Started at a `div` with `id="container"`
- Went to a `form` element
- Then to an `input` with `name="email"`
The path appears in error messages to help you locate problems:
```
Path : 'div#form.input[name=email]'
Error : 'type' is not found in Actual
```
### How Predicates Work
Predicates are objects that implement a `validate()` method:
```python
class Contains(AttrPredicate):
def validate(self, actual):
return self.value in actual
```
When matching encounters a predicate:
1. Gets the actual attribute value
2. Calls `predicate.validate(actual_value)`
3. If returns `False`, reports validation error
This allows flexible matching without hardcoding exact values.
### Error Output Generation
When a test fails, the matcher generates a side-by-side comparison:
**Process:**
1. Renders both `actual` and `expected` as tree structures
2. Compares them element by element
3. Marks differences with `^^^` characters
4. Aligns output for easy visual comparison
**Example:**
```
(div "id"="old" | (div "id"="new"
^^^^ |
```
The `^^^` appears under attributes or content that don't match.
### The find() Algorithm
`find()` uses depth-first search:
1. **Check current element**: Does it match the pattern?
- If yes: add to results
2. **Search children**: Recursively search all children
3. **Return all matches**: Collects matches from entire tree
**Key difference from matches()**: `find()` looks for any occurrence anywhere in the tree, while `matches()` validates exact structure.
---
## Best Practices
### Do's ✅
**Test only what matters**
```python
# ✅ Good - tests only the submit action
expected = Button("Submit", hx_post="/api/save")
# ❌ Bad - tests irrelevant details
expected = Button("Submit", hx_post="/api/save", id="btn-123", cls="btn btn-primary")
```
**Use predicates for dynamic values**
```python
# ✅ Good - flexible for generated IDs
expected = Div(id=StartsWith("generated-"))
# ❌ Bad - brittle, will break on regeneration
expected = Div(id="generated-12345")
```
**Structure tests in layers**
```python
# ✅ Good - separate concerns
# Test 1: Overall structure
matches(page, Div(Header(), Main(), Footer()))
# Test 2: Header details
header = find(page, Header())[0]
matches(header, Header(Nav(), Div(cls="user-menu")))
# ❌ Bad - everything in one giant test
matches(page, Div(
Header(Nav(...), Div(...)),
Main(...),
Footer(...)
))
```
**Verify element counts with find()**
```python
# ✅ Good - explicit count check
buttons = find(page, Button())
assert len(buttons) == 3, f"Expected 3 buttons, found {len(buttons)}"
# ❌ Bad - implicit assumption
button = find(page, Button())[0] # Fails if 0 or multiple buttons
```
### Don'ts ❌
**Don't test implementation details**
```python
# ❌ Bad - internal div structure might change
expected = Div(
Div(
Div(Button("Click"))
)
)
# ✅ Good - test the important element
expected = Div(Button("Click"))
```
**Don't use exact string matching**
```python
# ❌ Bad - fragile
assert str(component.render()) == '<div><p>Text</p></div>'
# ✅ Good - structural matching
matches(component.render(), Div(P("Text")))
```
**Don't over-specify**
```python
# ❌ Bad - tests too much
expected = Form(
Input(name="email", type="email", id="email-input", cls="form-control"),
Input(name="password", type="password", id="pwd-input", cls="form-control"),
Button("Submit", type="submit", id="submit-btn", cls="btn btn-primary")
)
# ✅ Good - tests what matters
expected = Form(
Input(name="email", type="email"),
Input(name="password", type="password"),
Button("Submit", type="submit")
)
```
### Performance Tips
**Reuse patterns**
```python
# Define reusable patterns
STANDARD_BUTTON = Button(cls=Contains("btn"))
# Use in multiple tests
matches(actual, Div(STANDARD_BUTTON))
```
**Use find() efficiently**
```python
# ✅ Good - specific pattern
buttons = find(page, Button(cls="primary"))
# ❌ Inefficient - too broad then filter
all_buttons = find(page, Button())
primary = [b for b in all_buttons if "primary" in b.attrs.get("cls", "")]
```
---
## Quick Reference
### Import Statements
```python
# Core functions
from myfasthtml.test.matcher import matches, find
# AttrPredicates
from myfasthtml.test.matcher import Contains, StartsWith, DoesNotContain, AnyValue
# ChildrenPredicates
from myfasthtml.test.matcher import Empty, NoChildren, AttributeForbidden
# For custom test objects
from myfasthtml.test.matcher import TestObject
```
### Common Patterns Cheatsheet
```python
# Basic validation
matches(actual, expected)
# Find and validate
elements = find(tree, pattern)
assert len(elements) == 1
matches(elements[0], detailed_pattern)
# Flexible attribute matching
expected = Div(
id=StartsWith("prefix-"),
cls=Contains("active"),
data_value=AnyValue()
)
# Empty element validation
expected = Div(Empty()) # No children, no attributes
expected = Div(NoChildren()) # No children, attributes OK
# Forbidden attribute
expected = Button(AttributeForbidden("disabled"))
# Multiple children
expected = Div(
Header(),
Main(),
Footer()
)
```
---
## Conclusion
The matcher module provides powerful tools for testing FastHTML components:
- **`matches()`** validates structure with precise, readable error messages
- **`find()`** locates elements anywhere in your HTML tree
- **Predicates** enable flexible, maintainable tests
By focusing on what matters and using the right patterns, you can write component tests that are both robust and easy to maintain.
**Next steps:**
- Practice with simple components first
- Gradually introduce predicates as needed
- Review error messages carefully - they guide you to the problem
- Refactor tests to remove duplication
Happy testing! 🧪

View File

@@ -133,6 +133,38 @@ class DoNotCheck:
desc: str = None
def _get_type(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 _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 []
class ErrorOutput:
def __init__(self, path, element, expected):
self.path = path
@@ -203,11 +235,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)
@@ -250,50 +282,35 @@ class ErrorOutput:
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_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_type = _get_type(element)
expected_type = _get_type(expected)
type_error = (" " if element_type == expected_type else "^") * len(element_type)
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 +360,200 @@ 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.",
_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)
for actual_child, expected_child in zip(actual_children, expected_children):
assert self.matches(actual_child, expected_child)
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 = 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 +566,118 @@ 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(attrs: dict):
"""Format attributes as a string."""
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(elt) 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 = []
def _type(x):
return type(x)
def _same(_ft, _expected):
if _ft == _expected:
"""
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 _elements_match(actual, expected):
"""Check if two elements are the same based on tag, attributes, and children."""
# Quick equality check
if actual == expected:
return True
if _ft.tag != _expected.tag:
# Check if both are FT elements
if not (hasattr(actual, "tag") and hasattr(expected, "tag")):
return False
for attr in _expected.attrs:
if attr not in _ft.attrs or _ft.attrs[attr] != _expected.attrs[attr]:
# Compare tags
if _get_type(actual) != _get_type(expected):
return False
# Compare attributes
expected_attrs = _get_attributes(expected)
actual_attrs = _get_attributes(actual)
for attr_name, attr_value in expected_attrs.items():
if attr_name not in actual_attrs or actual_attrs[attr_name] != attr_value:
return False
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):
if _type(current) != _type(current_expected):
def _search_tree(current, pattern):
"""Recursively search for pattern in the tree rooted at current."""
# Type mismatch - can't be the same
if type(current) != type(pattern):
return []
# For non-FT elements, simple equality check
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
ft_as_list = [ft] if not isinstance(ft, (list, tuple, set)) else ft
for current_ft in ft_as_list:
found = _find(current_ft, expected)
res.extend(found)
if len(res) == 0:
return [current] if current == pattern else []
# Check if current element matches
matches = []
if _elements_match(current, pattern):
matches.append(current)
# Recursively search in children
for child in _get_children(current):
matches.extend(_search_tree(child, pattern))
return matches
# Normalize input to list
elements_to_search = ft if isinstance(ft, (list, tuple, set)) else [ft]
# Search in all provided elements
all_matches = []
for element in elements_to_search:
all_matches.extend(_search_tree(element, expected))
# Raise error if nothing found
if not all_matches:
raise AssertionError(f"No element found for '{expected}'")
return res
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)
return all_matches
def _str_attrs(attrs: dict):

View File

@@ -63,8 +63,8 @@ class TestMatches:
([], [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(), Span(), "The types are different"),
([Div(), Span()], [Div(), Div()], "The types 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'"),
@@ -80,19 +80,19 @@ class TestMatches:
(Div(Span()), Div(Empty()), "The condition 'Empty()' is not satisfied"),
(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(Span()), Div(Div()), "The types 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"),
(Div(Span(), Span()), Div(Span(), Div()), "The types are different"),
(Div(Span(Div())), Div(Span(Span())), "The types are different"),
(Div(attr1="value1"), Div(AttributeForbidden("attr1")), "condition 'AttributeForbidden(attr1)' is not satisfied"),
(Div(123, "value"), TestObject(Dummy, attr1=123, attr2="value2"), "The types are different:"),
(Div(123, "value"), TestObject(Dummy, attr1=123, attr2="value2"), "The types are different"),
(Dummy(123, "value"), TestObject(Dummy, attr1=123, attr3="value3"), "'attr3' is not found in Actual"),
(Dummy(123, "value"), TestObject(Dummy, attr1=123, attr2="value2"), "The values are different for 'attr2'"),
(Div(Div(123, "value")), Div(TestObject(Dummy, attr1=123, attr2="value2")), "The types are different:"),
(Div(Div(123, "value")), Div(TestObject(Dummy, attr1=123, attr2="value2")), "The types are different"),
(Div(Dummy(123, "value")), Div(TestObject(Dummy, attr1=123, attr3="value3")), "'attr3' is not found in Actual"),
(Div(Dummy(123, "value")), Div(TestObject(Dummy, attr1=123, attr2="value2")), "are different for 'attr2'"),
(Div(123, "value"), TestObject("Dummy", attr1=123, attr2="value2"), "The types are different:"),
(Div(123, "value"), TestObject("Dummy", attr1=123, attr2="value2"), "The types are different"),
(Dummy(123, "value"), TestObject("Dummy", attr1=123, attr2=Contains("value2")), "The condition 'Contains(value2)' is not satisfied"),
])