Added unit tests for Layout.py

This commit is contained in:
2025-11-30 22:48:11 +01:00
parent 96ed447eae
commit 93cb477c21
8 changed files with 712 additions and 327 deletions

View File

@@ -209,79 +209,301 @@ class TestControlRender:
- When to use predicates (Contains, StartsWith, AnyValue, etc.) - When to use predicates (Contains, StartsWith, AnyValue, etc.)
- How to test only what matters (not every detail) - How to test only what matters (not every detail)
- How to read error messages with `^^^` markers - How to read error messages with `^^^` markers
3. **Apply the best practices**: 3. **Apply the best practices** detailed below
- Test only important structural elements and attributes
- Use predicates for dynamic/generated values ---
- Don't over-specify tests with irrelevant details
- Structure tests in layers (overall structure, then details) #### **UTR-11.1 : Pattern de test en trois étapes (RÈGLE FONDAMENTALE)**
**Principe :** C'est le pattern par défaut à appliquer pour tous les tests de rendu. Les autres règles sont des compléments à ce pattern.
**Les trois étapes :**
1. **Extraire l'élément à tester** avec `find_one()` ou `find()` à partir du rendu global
2. **Définir la structure attendue** avec `expected = ...`
3. **Comparer** avec `assert matches(element, expected)`
**Pourquoi :** Ce pattern permet des messages d'erreur clairs et sépare la recherche de l'élément de la validation de sa structure.
**Exemple :**
```python
# ✅ BON - Pattern en trois étapes
def test_header_has_two_sides(self, layout):
"""Test that there is a left and right header section."""
# Étape 1 : Extraire l'élément à tester
header = find_one(layout.render(), Header(cls=Contains("mf-layout-header")))
# Étape 2 : Définir la structure attendue
expected = Header(
Div(id=f"{layout._id}_hl"),
Div(id=f"{layout._id}_hr"),
)
# Étape 3 : Comparer
assert matches(header, expected)
# ❌ À ÉVITER - Tout imbriqué en une ligne
def test_header_has_two_sides(self, layout):
assert matches(
find_one(layout.render(), Header(cls=Contains("mf-layout-header"))),
Header(Div(id=f"{layout._id}_hl"), Div(id=f"{layout._id}_hr"))
)
```
**Note :** Cette règle s'applique à presque tous les tests. Les autres règles ci-dessous complètent ce pattern fondamental.
---
#### **COMMENT CHERCHER LES ÉLÉMENTS**
---
#### **UTR-11.2 : Privilégier la recherche par ID**
**Principe :** Toujours chercher un élément par son `id` quand il en a un, plutôt que par classe ou autre attribut.
**Pourquoi :** Plus robuste, plus rapide, et ciblé (un ID est unique).
**Exemple :**
```python
# ✅ BON - recherche par ID
drawer = find_one(layout.render(), Div(id=f"{layout._id}_ld"))
# ❌ À ÉVITER - recherche par classe quand un ID existe
drawer = find_one(layout.render(), Div(cls=Contains("mf-layout-left-drawer")))
```
---
#### **UTR-11.3 : Utiliser `find_one()` vs `find()` selon le contexte**
**Principe :**
- `find_one()` : Quand vous cherchez un élément unique et voulez tester sa structure complète
- `find()` : Quand vous cherchez plusieurs éléments ou voulez compter/vérifier leur présence
**Exemples :**
```python
# ✅ BON - find_one pour structure unique
header = find_one(layout.render(), Header(cls=Contains("mf-layout-header")))
expected = Header(...)
assert matches(header, expected)
# ✅ BON - find pour compter
resizers = find(drawer, Div(cls=Contains("mf-resizer-left")))
assert len(resizers) == 1, "Left drawer should contain exactly one resizer element"
```
---
#### **COMMENT SPÉCIFIER LA STRUCTURE ATTENDUE**
---
#### **UTR-11.4 : Toujours utiliser `Contains()` pour les attributs `cls` et `style`**
**Principe :**
- Pour `cls` : Les classes CSS peuvent être dans n'importe quel ordre. Testez uniquement les classes importantes avec `Contains()`.
- Pour `style` : Les propriétés CSS peuvent être dans n'importe quel ordre. Testez uniquement les propriétés importantes avec `Contains()`.
**Pourquoi :** Évite les faux négatifs dus à l'ordre des classes/propriétés ou aux espaces.
**Exemples :**
```python
# ✅ BON - Contains pour cls (une ou plusieurs classes)
expected = Div(cls=Contains("mf-layout-drawer"))
expected = Div(cls=Contains("mf-layout-drawer", "mf-layout-left-drawer"))
# ✅ BON - Contains pour style
expected = Div(style=Contains("width: 250px"))
# ❌ À ÉVITER - test exact des classes
expected = Div(cls="mf-layout-drawer mf-layout-left-drawer")
# ❌ À ÉVITER - test exact du style complet
expected = Div(style="width: 250px; overflow: hidden; display: flex;")
```
---
#### **UTR-11.5 : Utiliser `TestIcon()` pour tester la présence d'une icône**
**Principe :** Utilisez `TestIcon("icon_name")` pour tester la présence d'une icône SVG dans le rendu.
**Le paramètre `name` :**
- **Nom exact** : Utilisez le nom exact de l'import (ex: `TestIcon("panel_right_expand20_regular")`) pour valider une icône spécifique
- **`name=""`** (chaîne vide) : Valide **n'importe quelle icône**. Le test sera passant dès que la structure affichant une icône sera trouvée, peu importe laquelle.
- **JAMAIS `name="svg"`** : Cela causera des échecs de test
**Exemples :**
```python
from myfasthtml.icons.fluent import panel_right_expand20_regular
# ✅ BON - Tester une icône spécifique
expected = Header(
Div(
TestIcon("panel_right_expand20_regular"),
cls=Contains("flex", "gap-1")
)
)
# ✅ BON - Tester la présence de n'importe quelle icône
expected = Div(
TestIcon(""), # Accepte n'importe quelle icône
cls=Contains("icon-wrapper")
)
# ❌ À ÉVITER - name="svg"
expected = Div(TestIcon("svg")) # ERREUR : causera un échec
```
---
#### **UTR-11.6 : Utiliser `TestScript()` pour tester les scripts JavaScript**
**Principe :** Utilisez `TestScript(code_fragment)` pour vérifier la présence de code JavaScript. Testez uniquement le fragment important, pas le script complet.
**Exemple :**
```python
# ✅ BON - TestScript avec fragment important
script = find_one(layout.render(), Script())
expected = TestScript(f"initResizer('{layout._id}');")
assert matches(script, expected)
# ❌ À ÉVITER - tester tout le contenu du script
expected = Script("(function() { const id = '...'; initResizer(id); })()")
```
---
#### **COMMENT DOCUMENTER LES TESTS**
---
#### **UTR-11.7 : Justifier le choix des éléments testés**
**Principe :** Dans la section de documentation du test (après le docstring de description), expliquez **pourquoi chaque élément ou attribut testé a été choisi**. Qu'est-ce qui le rend important pour la fonctionnalité ?
**Ce qui compte :** Pas la formulation exacte ("Why these elements matter" vs "Why this test matters"), mais **l'explication de la pertinence de ce qui est testé**.
**Exemples :**
```python
def test_empty_layout_is_rendered(self, layout):
"""Test that Layout renders with all main structural sections.
Why these elements matter:
- 6 children: Verifies all main sections are rendered (header, drawers, main, footer, script)
- _id: Essential for layout identification and resizer initialization
- cls="mf-layout": Root CSS class for layout styling
"""
expected = Div(...)
assert matches(layout.render(), expected)
def test_left_drawer_is_rendered_when_open(self, layout):
"""Test that left drawer renders with correct classes when open.
Why these elements matter:
- _id: Required for targeting drawer in HTMX updates
- cls Contains "mf-layout-drawer": Base drawer class for styling
- cls Contains "mf-layout-left-drawer": Left-specific drawer positioning
- style Contains width: Drawer width must be applied for sizing
"""
layout._state.left_drawer_open = True
drawer = find_one(layout.render(), Div(id=f"{layout._id}_ld"))
expected = Div(
_id=f"{layout._id}_ld",
cls=Contains("mf-layout-drawer", "mf-layout-left-drawer"),
style=Contains("width: 250px")
)
assert matches(drawer, expected)
```
**Points clés :**
- Expliquez pourquoi l'attribut/élément est important (fonctionnalité, HTMX, styling, etc.)
- Pas besoin de suivre une formulation rigide
- L'important est la **justification du choix**, pas le format
---
#### **UTR-11.8 : Tests de comptage avec messages explicites**
**Principe :** Quand vous comptez des éléments avec `assert len()`, ajoutez TOUJOURS un message explicite qui explique pourquoi ce nombre est attendu.
**Exemple :**
```python
# ✅ BON - message explicatif
resizers = find(drawer, Div(cls=Contains("mf-resizer-left")))
assert len(resizers) == 1, "Left drawer should contain exactly one resizer element"
dividers = find(content, Div(cls="divider"))
assert len(dividers) >= 1, "Groups should be separated by dividers"
# ❌ À ÉVITER - pas de message
assert len(resizers) == 1
```
---
#### **AUTRES RÈGLES IMPORTANTES**
---
**Mandatory render test rules:** **Mandatory render test rules:**
1. **Test naming**: Use descriptive names like `test_empty_layout_is_rendered()` not `test_layout_renders_with_all_sections()` 1. **Test naming**: Use descriptive names like `test_empty_layout_is_rendered()` not `test_layout_renders_with_all_sections()`
2. **Documentation format**: Every render test MUST have a docstring with: 2. **Documentation format**: Every render test MUST have a docstring with:
- First line: Brief description of what is being tested - First line: Brief description of what is being tested
- Blank line - Blank line
- "Why these elements matter:" or "Why this test matters:" section - Justification section explaining why tested elements matter (see UTR-11.7)
- List of important elements/attributes being tested with explanations (in English) - List of important elements/attributes being tested with explanations (in English)
3. **No inline comments**: Do NOT add comments on each line of the expected structure
4. **Icon testing**: Use `Div(NotStr(name="icon_name"))` to test SVG icons
- Use the exact icon name from the import (e.g., `name="panel_right_expand20_regular"`)
- Use `name=""` (empty string) if the import is not explicit
- NEVER use `name="svg"` - it will cause test failures
5. **Component testing**: Use `TestObject(ComponentClass)` to test presence of components
6. **Explanation focus**: In "Why these elements matter", refer to the logical element (e.g., "Svg") not the technical implementation (e.g., "Div(NotStr(...))")
**Example of proper render test:** 3. **No inline comments**: Do NOT add comments on each line of the expected structure (except for structural clarification in global layout tests like `# left drawer`)
4. **Component testing**: Use `TestObject(ComponentClass)` to test presence of components
5. **Test organization for Controls**: Organize tests into thematic classes:
- `TestControlBehaviour`: Tests for control behavior and logic
- `TestControlRender`: Tests for control HTML rendering
6. **Fixture usage**: In `TestControlRender`, use a pytest fixture to create the control instance:
```python ```python
from myfasthtml.test.matcher import matches, find, Contains, NotStr, TestObject
from fasthtml.common import Div, Header, Button
class TestControlRender: class TestControlRender:
def test_empty_control_is_rendered(self, root_instance): @pytest.fixture
"""Test that control renders with main structural sections. def layout(self, root_instance):
return Layout(root_instance, app_name="Test App")
Why these elements matter: def test_something(self, layout):
- 3 children: Verifies header, body, and footer are all rendered # layout is injected automatically
- _id: Essential for HTMX targeting and component identification
- cls="control-wrapper": Root CSS class for styling
"""
control = MyControl(root_instance)
expected = Div(
Header(),
Div(),
Div(),
_id=control._id,
cls="control-wrapper"
)
assert matches(control.render(), expected)
def test_header_with_icon_is_rendered(self, root_instance):
"""Test that header renders with action icon.
Why these elements matter:
- Svg: Action icon is essential for user interaction
- TestObject(ActionButton): ActionButton component must be present
- cls="header-bar": Required CSS class for header styling
"""
control = MyControl(root_instance)
header = control._mk_header()
expected = Header(
Div(NotStr(name="action_icon_20_regular")),
TestObject(ActionButton),
cls="header-bar"
)
assert matches(header, expected)
``` ```
---
#### **Résumé : Les 8 règles UTR-11**
**Pattern fondamental**
- **UTR-11.1** : Pattern en trois étapes (extraire → définir expected → comparer)
**Comment chercher**
- **UTR-11.2** : Privilégier recherche par ID
- **UTR-11.3** : `find_one()` vs `find()` selon contexte
**Comment spécifier**
- **UTR-11.4** : Toujours `Contains()` pour `cls` et `style`
- **UTR-11.5** : `TestIcon()` pour tester la présence d'icônes
- **UTR-11.6** : `TestScript()` pour JavaScript
**Comment documenter**
- **UTR-11.7** : Justifier le choix des éléments testés
- **UTR-11.8** : Messages explicites pour `assert len()`
---
**When proposing render tests:** **When proposing render tests:**
- Reference specific patterns from the documentation - Reference specific patterns from the documentation
- Explain why you chose to test certain elements and not others - Explain why you chose to test certain elements and not others
- Justify the use of predicates vs exact values - Justify the use of predicates vs exact values
- Always include "Why these elements matter" documentation - Always include justification documentation (see UTR-11.7)
### UTR-12: Test Workflow ### UTR-12: Test Workflow

51
docs/Layout.md Normal file
View File

@@ -0,0 +1,51 @@
# Layout control
## Overview
This component renders the global layout of the application.
This is only one instance per session.
## State
| Name | Type | Description | Default |
|----------------------|---------|----------------------------------|---------|
| `left_drawer_open` | boolean | True if the left drawer is open | True |
| `right_drawer_open` | boolean | True if the right drawer is open | True |
| `left_drawer_width` | integer | Width of the left drawer | 250 |
| `right_drawer_width` | integer | Width of the right drawer | 250 |
## Commands
| Name | Description |
|-----------------------------------------|----------------------------------------------------------------------------------------|
| `toggle_drawer(side)` | Toggles the drawer on the specified side |
| `update_drawer_width(side, width=None)` | Updates the drawer width on the specified side. The width is given by the HTMX request |
## Ids
| Name | Description |
|-------------|-------------------|
| `layout` | Singleton |
| `layout_h` | header |
| `layout_hl` | header left side |
| `layout_hr` | header right side |
| `layout_f` | footer |
| `layout_fl` | footer left side |
| `layout_fr` | footer right side |
| `layout_ld` | left drawer |
| `layout_rd` | right drawer |
## High Level Hierarchical Structure
```
MyFastHtml
├── src
│ ├── myfasthtml/ # Main library code
│ │ ├── core/commands.py # Command definitions
│ │ ├── controls/button.py # Control helpers
│ │ └── pages/LoginPage.py # Predefined Login page
│ └── ...
├── tests # Unit and integration tests
├── LICENSE # License file (MIT)
├── README.md # Project documentation
└── pyproject.toml # Build configuration
```

View File

@@ -83,7 +83,7 @@ def create_sample_treeview(parent):
def index(session): def index(session):
session_instance = UniqueInstance(session=session, _id=Ids.UserSession) session_instance = UniqueInstance(session=session, _id=Ids.UserSession)
layout = Layout(session_instance, "Testing Layout") layout = Layout(session_instance, "Testing Layout")
layout.set_footer("Goodbye World") layout.footer_left.add("Goodbye World")
tabs_manager = TabsManager(layout, _id=f"-tabs_manager") tabs_manager = TabsManager(layout, _id=f"-tabs_manager")
add_tab = tabs_manager.commands.add_tab add_tab = tabs_manager.commands.add_tab

View File

@@ -37,12 +37,13 @@ class Commands(BaseCommands):
def toggle_drawer(self, side: Literal["left", "right"]): def toggle_drawer(self, side: Literal["left", "right"]):
return Command("ToggleDrawer", f"Toggle {side} layout drawer", self._owner.toggle_drawer, side) return Command("ToggleDrawer", f"Toggle {side} layout drawer", self._owner.toggle_drawer, side)
def update_drawer_width(self, side: Literal["left", "right"]): def update_drawer_width(self, side: Literal["left", "right"], width: int = None):
""" """
Create a command to update drawer width. Create a command to update drawer width.
Args: Args:
side: Which drawer to update ("left" or "right") side: Which drawer to update ("left" or "right")
width: New width in pixels. Given by the HTMX request
Returns: Returns:
Command: Command object for updating drawer width Command: Command object for updating drawer width
@@ -123,16 +124,6 @@ class Layout(SingleInstance):
self.header_right = self.Content(self) self.header_right = self.Content(self)
self.footer_left = self.Content(self) self.footer_left = self.Content(self)
self.footer_right = self.Content(self) self.footer_right = self.Content(self)
self._footer_content = None
def set_footer(self, content):
"""
Set the footer content.
Args:
content: FastHTML component(s) or content for the footer
"""
self._footer_content = content
def set_main(self, content): def set_main(self, content):
""" """
@@ -190,15 +181,17 @@ class Layout(SingleInstance):
return Header( return Header(
Div( # left Div( # left
self._mk_left_drawer_icon(), self._mk_left_drawer_icon(),
*self.header_left.get_content(), *self._mk_content_wrapper(self.header_left, horizontal=True, show_group_name=False).children,
cls="flex gap-1" cls="flex gap-1",
id=f"{self._id}_hl"
), ),
Div( # right Div( # right
*self.header_right.get_content()[None], *self._mk_content_wrapper(self.header_right, horizontal=True, show_group_name=False).children,
UserProfile(self), UserProfile(self),
cls="flex gap-1" cls="flex gap-1",
id=f"{self._id}_hr"
), ),
cls="mf-layout-header" cls="mf-layout-header",
) )
def _mk_footer(self): def _mk_footer(self):
@@ -208,9 +201,17 @@ class Layout(SingleInstance):
Returns: Returns:
Footer: FastHTML Footer component Footer: FastHTML Footer component
""" """
footer_content = self._footer_content if self._footer_content else ""
return Footer( return Footer(
footer_content, Div( # left
*self._mk_content_wrapper(self.footer_left, horizontal=True, show_group_name=False).children,
cls="flex gap-1",
id=f"{self._id}_fl"
),
Div( # right
*self._mk_content_wrapper(self.footer_right, horizontal=True, show_group_name=False).children,
cls="flex gap-1",
id=f"{self._id}_fr"
),
cls="mf-layout-footer footer sm:footer-horizontal" cls="mf-layout-footer footer sm:footer-horizontal"
) )
@@ -299,6 +300,20 @@ class Layout(SingleInstance):
id=f"{self._id}_rdi", id=f"{self._id}_rdi",
command=self.commands.toggle_drawer("right")) command=self.commands.toggle_drawer("right"))
@staticmethod
def _mk_content_wrapper(content: Content, show_group_name: bool = True, horizontal: bool = False):
return Div(
*[
(
Div(cls=f"divider {'divider-horizontal' if horizontal else ''}") if index > 0 else None,
group_ft if show_group_name else None,
*[item for item in content.get_content()[group_name]]
)
for index, (group_name, group_ft) in enumerate(content.get_groups())
],
cls="mf-layout-drawer-content"
)
def render(self): def render(self):
""" """
Render the complete layout. Render the complete layout.

View File

@@ -1,9 +1,11 @@
import re import re
from dataclasses import dataclass from dataclasses import dataclass
from typing import Optional
from fastcore.basics import NotStr from fastcore.basics import NotStr
from fastcore.xml import FT
from myfasthtml.core.utils import quoted_str from myfasthtml.core.utils import quoted_str, snake_to_pascal
from myfasthtml.test.testclient import MyFT from myfasthtml.test.testclient import MyFT
MISSING_ATTR = "** MISSING **" MISSING_ATTR = "** MISSING **"
@@ -17,7 +19,18 @@ class Predicate:
raise NotImplementedError raise NotImplementedError
def __str__(self): 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): def __repr__(self):
return f"{self.__class__.__name__}({self.value if self.value is not None else ''})" return f"{self.__class__.__name__}({self.value if self.value is not None else ''})"
@@ -47,20 +60,28 @@ class StartsWith(AttrPredicate):
return actual.startswith(self.value) return actual.startswith(self.value)
class Contains(AttrPredicate): class EndsWith(AttrPredicate):
def __init__(self, value): def __init__(self, value):
super().__init__(value) super().__init__(value)
def validate(self, actual): def validate(self, actual):
return self.value in actual return actual.endswith(self.value)
class Contains(AttrPredicate):
def __init__(self, *value):
super().__init__(value)
def validate(self, actual):
return all(val in actual for val in self.value)
class DoesNotContain(AttrPredicate): class DoesNotContain(AttrPredicate):
def __init__(self, value): def __init__(self, *value):
super().__init__(value) super().__init__(value)
def validate(self, actual): def validate(self, actual):
return self.value not in actual return all(val not in actual for val in self.value)
class AnyValue(AttrPredicate): class AnyValue(AttrPredicate):
@@ -75,6 +96,14 @@ class AnyValue(AttrPredicate):
return actual is not None 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): class ChildrenPredicate(Predicate):
""" """
Predicate given as a child of an element. Predicate given as a child of an element.
@@ -122,12 +151,33 @@ class TestObject:
self.attrs = kwargs self.attrs = kwargs
class TestIcon(TestObject):
def __init__(self, name: Optional[str] = ''):
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}'))
]
def __str__(self):
return f'<div><svg name="{self.name}" .../></div>'
class TestCommand(TestObject): class TestCommand(TestObject):
def __init__(self, name, **kwargs): def __init__(self, name, **kwargs):
super().__init__("Command", **kwargs) super().__init__("Command", **kwargs)
self.attrs = {"name": name} | kwargs # name should be first 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 @dataclass
class DoNotCheck: class DoNotCheck:
desc: str = None desc: str = None
@@ -136,7 +186,7 @@ class DoNotCheck:
def _get_type(x): def _get_type(x):
if hasattr(x, "tag"): if hasattr(x, "tag"):
return x.tag return x.tag
if isinstance(x, TestObject): if isinstance(x, (TestObject, TestIcon)):
return x.cls.__name__ if isinstance(x.cls, type) else str(x.cls) return x.cls.__name__ if isinstance(x.cls, type) else str(x.cls)
return type(x).__name__ return type(x).__name__
@@ -451,7 +501,7 @@ class Matcher:
# Check if attribute exists # Check if attribute exists
if actual_value == MISSING_ATTR: if actual_value == MISSING_ATTR:
self._assert_error(f"'{expected_attr}' is not found in Actual.", self._assert_error(f"'{expected_attr}' is not found in Actual. (attributes: {self._str_attrs(actual)})",
_actual=actual, _actual=actual,
_expected=expected) _expected=expected)
@@ -517,7 +567,7 @@ class Matcher:
def _match_notstr(self, actual, expected): def _match_notstr(self, actual, expected):
"""Match NotStr type.""" """Match NotStr type."""
to_compare = actual.s.lstrip('\n').lstrip() to_compare = _get_attr(actual, "s").lstrip('\n').lstrip()
assert to_compare.startswith(expected.s), self._error_msg("Notstr values are different: ", assert to_compare.startswith(expected.s), self._error_msg("Notstr values are different: ",
_actual=to_compare, _actual=to_compare,
_expected=expected.s) _expected=expected.s)
@@ -567,8 +617,9 @@ class Matcher:
return elt.__class__.__name__ return elt.__class__.__name__
@staticmethod @staticmethod
def _str_attrs(attrs: dict): def _str_attrs(element):
"""Format attributes as a string.""" """Format attributes as a string."""
attrs = _get_attributes(element)
return " ".join(f'"{attr_name}"="{attr_value}"' for attr_name, attr_value in attrs.items()) return " ".join(f'"{attr_name}"="{attr_value}"' for attr_name, attr_value in attrs.items())
@staticmethod @staticmethod
@@ -613,13 +664,18 @@ def find(ft, expected):
def _elements_match(actual, expected): def _elements_match(actual, expected):
"""Check if two elements are the same based on tag, attributes, and children.""" """Check if two elements are the same based on tag, attributes, and children."""
# Quick equality check if isinstance(expected, DoNotCheck):
if actual == expected:
return True return True
# Check if both are FT elements if isinstance(expected, NotStr):
if not (hasattr(actual, "tag") and hasattr(expected, "tag")): to_compare = _get_attr(actual, "s").lstrip('\n').lstrip()
return False 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 # Compare tags
if _get_type(actual) != _get_type(expected): if _get_type(actual) != _get_type(expected):
@@ -627,11 +683,16 @@ def find(ft, expected):
# Compare attributes # Compare attributes
expected_attrs = _get_attributes(expected) expected_attrs = _get_attributes(expected)
actual_attrs = _get_attributes(actual) for attr_name, expected_attr_value in expected_attrs.items():
actual_attr_value = _get_attr(actual, attr_name)
for attr_name, attr_value in expected_attrs.items(): # attribute is missing
if attr_name not in actual_attrs or actual_attrs[attr_name] != attr_value: if actual_attr_value == MISSING_ATTR:
return False 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
# Compare children recursively # Compare children recursively
expected_children = _get_children(expected) expected_children = _get_children(expected)
@@ -646,20 +707,12 @@ def find(ft, expected):
def _search_tree(current, pattern): def _search_tree(current, pattern):
"""Recursively search for pattern in the tree rooted at current.""" """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 == pattern else []
# Check if current element matches # Check if current element matches
matches = [] matches = []
if _elements_match(current, pattern): if _elements_match(current, pattern):
matches.append(current) matches.append(current)
# Recursively search in children # Recursively search in children, in the case that the pattern also appears in children
for child in _get_children(current): for child in _get_children(current):
matches.extend(_search_tree(child, pattern)) matches.extend(_search_tree(child, pattern))
@@ -680,5 +733,11 @@ def find(ft, expected):
return all_matches return all_matches
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): def _str_attrs(attrs: dict):
return " ".join(f'"{attr_name}"="{attr_value}"' for attr_name, attr_value in attrs.items()) return " ".join(f'"{attr_name}"="{attr_value}"' for attr_name, attr_value in attrs.items())

View File

@@ -4,9 +4,8 @@ import shutil
import pytest import pytest
from fasthtml.components import * from fasthtml.components import *
from myfasthtml.controls.Layout import Layout, LayoutState from myfasthtml.controls.Layout import Layout
from myfasthtml.controls.UserProfile import UserProfile from myfasthtml.test.matcher import matches, find, Contains, find_one, TestIcon, TestScript
from myfasthtml.test.matcher import matches, find, Contains, NotStr, TestObject
from .conftest import root_instance from .conftest import root_instance
@@ -238,7 +237,11 @@ class TestLayoutBehaviour:
class TestLayoutRender: class TestLayoutRender:
"""Tests for Layout HTML rendering.""" """Tests for Layout HTML rendering."""
def test_empty_layout_is_rendered(self, root_instance): @pytest.fixture
def layout(self, root_instance):
return Layout(root_instance, app_name="Test App")
def test_empty_layout_is_rendered(self, layout):
"""Test that Layout renders with all main structural sections. """Test that Layout renders with all main structural sections.
Why these elements matter: Why these elements matter:
@@ -246,13 +249,11 @@ class TestLayoutRender:
- _id: Essential for layout identification and resizer initialization - _id: Essential for layout identification and resizer initialization
- cls="mf-layout": Root CSS class for layout styling - cls="mf-layout": Root CSS class for layout styling
""" """
layout = Layout(root_instance, app_name="Test App")
expected = Div( expected = Div(
Header(), Header(),
Div(), Div(), # left drawer
Main(), Main(),
Div(), Div(), # right drawer
Footer(), Footer(),
Script(), Script(),
_id=layout._id, _id=layout._id,
@@ -261,26 +262,40 @@ class TestLayoutRender:
assert matches(layout.render(), expected) assert matches(layout.render(), expected)
def test_header_with_drawer_icons_is_rendered(self, root_instance): def test_header_has_two_sides(self, layout):
"""Test that there is a left and right header section."""
header = find_one(layout.render(), Header(cls="mf-layout-header"))
expected = Header(
Div(id=f"{layout._id}_hl"),
Div(id=f"{layout._id}_hr"),
)
assert matches(header, expected)
def test_footer_has_two_sides(self, layout):
"""Test that there is a left and right footer section."""
footer = find_one(layout.render(), Footer(cls=Contains("mf-layout-footer")))
expected = Footer(
Div(id=f"{layout._id}_fl"),
Div(id=f"{layout._id}_fr"),
)
assert matches(footer, expected)
def test_header_with_drawer_icons_is_rendered(self, layout):
"""Test that header renders with drawer toggle icons. """Test that header renders with drawer toggle icons.
Why these elements matter: Why these elements matter:
- 2 Div children: Left/right header structure for organizing controls - Only the first div is required to test the presence of the icon
- Svg: Toggle icon is essential for user interaction with drawer - Use TestIcon to test the existence of an icon
- TestObject(UserProfile): UserProfile component must be present in header
- cls="flex gap-1": CSS critical for horizontal alignment of header items
- cls="mf-layout-header": Root header class for styling
""" """
layout = Layout(root_instance, app_name="Test App") header = find_one(layout.render(), Header(cls="mf-layout-header"))
header = layout._mk_header()
expected = Header( expected = Header(
Div( Div(
Div(NotStr(name="panel_right_expand20_regular")), TestIcon("panel_right_expand20_regular"),
cls="flex gap-1"
),
Div(
TestObject(UserProfile),
cls="flex gap-1" cls="flex gap-1"
), ),
cls="mf-layout-header" cls="mf-layout-header"
@@ -288,59 +303,43 @@ class TestLayoutRender:
assert matches(header, expected) assert matches(header, expected)
def test_footer_is_rendered(self, root_instance): def test_main_content_is_rendered_with_some_element(self, layout):
"""Test that footer renders with correct structure.
Why these elements matter:
- cls Contains "mf-layout-footer": Root footer class for styling
- cls Contains "footer": DaisyUI base footer class
"""
layout = Layout(root_instance, app_name="Test App")
footer = layout._mk_footer()
expected = Footer(
cls=Contains("mf-layout-footer")
)
assert matches(footer, expected)
def test_main_content_is_rendered(self, root_instance):
"""Test that main content area renders correctly. """Test that main content area renders correctly.
Why these elements matter: Why these elements matter:
- cls="mf-layout-main": Root main class for styling - cls="mf-layout-main": Root main class for styling
""" """
layout = Layout(root_instance, app_name="Test App") layout.set_main(Div("Main content"))
main = layout._mk_main() main = find_one(layout.render(), Main(cls="mf-layout-main"))
expected = Main( expected = Main(
Div("Main content"),
cls="mf-layout-main" cls="mf-layout-main"
) )
assert matches(main, expected) assert matches(main, expected)
def test_left_drawer_is_rendered_when_open(self, root_instance): def test_left_drawer_is_rendered_when_open(self, layout):
"""Test that left drawer renders with correct classes when open. """Test that left drawer renders with correct classes when open.
Why these elements matter: Why these elements matter:
- _id: Required for targeting drawer in HTMX updates - _id: Required for targeting drawer in HTMX updates. search by id whenever possible
- cls Contains "mf-layout-drawer": Base drawer class for styling - cls Contains "mf-layout-drawer": Base drawer class for styling
- cls Contains "mf-layout-left-drawer": Left-specific drawer positioning - cls Contains "mf-layout-left-drawer": Left-specific drawer positioning
- style Contains width: Drawer width must be applied for sizing - style Contains width: Drawer width must be applied for sizing
""" """
layout = Layout(root_instance, app_name="Test App") layout._state.left_drawer_open = True
drawer = layout._mk_left_drawer() drawer = find_one(layout.render(), Div(id=f"{layout._id}_ld"))
expected = Div( expected = Div(
_id=f"{layout._id}_ld", _id=f"{layout._id}_ld",
cls=Contains("mf-layout-drawer"), cls=Contains("mf-layout-drawer", "mf-layout-left-drawer"),
#cls=Contains("mf-layout-left-drawer"),
style=Contains("width: 250px") style=Contains("width: 250px")
) )
assert matches(drawer, expected) assert matches(drawer, expected)
def test_left_drawer_has_collapsed_class_when_closed(self, root_instance): def test_left_drawer_has_collapsed_class_when_closed(self, layout):
"""Test that left drawer renders with collapsed class when closed. """Test that left drawer renders with collapsed class when closed.
Why these elements matter: Why these elements matter:
@@ -348,9 +347,8 @@ class TestLayoutRender:
- cls Contains "collapsed": Class triggers CSS hiding animation - cls Contains "collapsed": Class triggers CSS hiding animation
- style Contains "width: 0px": Zero width is crucial for collapse animation - style Contains "width: 0px": Zero width is crucial for collapse animation
""" """
layout = Layout(root_instance, app_name="Test App")
layout._state.left_drawer_open = False layout._state.left_drawer_open = False
drawer = layout._mk_left_drawer() drawer = find_one(layout.render(), Div(id=f"{layout._id}_ld"))
expected = Div( expected = Div(
_id=f"{layout._id}_ld", _id=f"{layout._id}_ld",
@@ -360,7 +358,7 @@ class TestLayoutRender:
assert matches(drawer, expected) assert matches(drawer, expected)
def test_right_drawer_is_rendered_when_open(self, root_instance): def test_right_drawer_is_rendered_when_open(self, layout):
"""Test that right drawer renders with correct classes when open. """Test that right drawer renders with correct classes when open.
Why these elements matter: Why these elements matter:
@@ -369,19 +367,18 @@ class TestLayoutRender:
- cls Contains "mf-layout-right-drawer": Right-specific drawer positioning - cls Contains "mf-layout-right-drawer": Right-specific drawer positioning
- style Contains width: Drawer width must be applied for sizing - style Contains width: Drawer width must be applied for sizing
""" """
layout = Layout(root_instance, app_name="Test App") layout._state.right_drawer_open = True
drawer = layout._mk_right_drawer() drawer = find_one(layout.render(), Div(id=f"{layout._id}_rd"))
expected = Div( expected = Div(
_id=f"{layout._id}_rd", _id=f"{layout._id}_rd",
cls=Contains("mf-layout-drawer"), cls=Contains("mf-layout-drawer", "mf-layout-right-drawer"),
#cls=Contains("mf-layout-right-drawer"),
style=Contains("width: 250px") style=Contains("width: 250px")
) )
assert matches(drawer, expected) assert matches(drawer, expected)
def test_right_drawer_has_collapsed_class_when_closed(self, root_instance): def test_right_drawer_has_collapsed_class_when_closed(self, layout):
"""Test that right drawer renders with collapsed class when closed. """Test that right drawer renders with collapsed class when closed.
Why these elements matter: Why these elements matter:
@@ -389,9 +386,8 @@ class TestLayoutRender:
- cls Contains "collapsed": Class triggers CSS hiding animation - cls Contains "collapsed": Class triggers CSS hiding animation
- style Contains "width: 0px": Zero width is crucial for collapse animation - style Contains "width: 0px": Zero width is crucial for collapse animation
""" """
layout = Layout(root_instance, app_name="Test App")
layout._state.right_drawer_open = False layout._state.right_drawer_open = False
drawer = layout._mk_right_drawer() drawer = find_one(layout.render(), Div(id=f"{layout._id}_rd"))
expected = Div( expected = Div(
_id=f"{layout._id}_rd", _id=f"{layout._id}_rd",
@@ -401,15 +397,14 @@ class TestLayoutRender:
assert matches(drawer, expected) assert matches(drawer, expected)
def test_drawer_width_is_applied_as_style(self, root_instance): def test_drawer_width_is_applied_as_style(self, layout):
"""Test that custom drawer width is applied as inline style. """Test that custom drawer width is applied as inline style.
Why this test matters: Why this test matters:
- style Contains "width: 300px": Verifies that width updates are reflected in style attribute - style Contains "width: 300px": Verifies that width updates are reflected in style attribute
""" """
layout = Layout(root_instance, app_name="Test App")
layout._state.left_drawer_width = 300 layout._state.left_drawer_width = 300
drawer = layout._mk_left_drawer() drawer = find_one(layout.render(), Div(id=f"{layout._id}_ld"))
expected = Div( expected = Div(
style=Contains("width: 300px") style=Contains("width: 300px")
@@ -417,46 +412,40 @@ class TestLayoutRender:
assert matches(drawer, expected) assert matches(drawer, expected)
def test_left_drawer_has_resizer_element(self, root_instance): def test_left_drawer_has_resizer_element(self, layout):
"""Test that left drawer contains resizer element. """Test that left drawer contains resizer element.
Why this test matters: Why this test matters:
- Resizer element must be present for drawer width adjustment - Resizer element must be present for drawer width adjustment
- cls "mf-resizer-left": Left-specific resizer for correct edge positioning - cls "mf-resizer-left": Left-specific resizer for correct edge positioning
""" """
layout = Layout(root_instance, app_name="Test App") drawer = find(layout.render(), Div(id=f"{layout._id}_ld"))
drawer = layout._mk_left_drawer()
resizers = find(drawer, Div(cls=Contains("mf-resizer-left"))) resizers = find(drawer, Div(cls=Contains("mf-resizer-left")))
assert len(resizers) == 1, "Left drawer should contain exactly one resizer element" assert len(resizers) == 1, "Left drawer should contain exactly one resizer element"
def test_right_drawer_has_resizer_element(self, root_instance): def test_right_drawer_has_resizer_element(self, layout):
"""Test that right drawer contains resizer element. """Test that right drawer contains resizer element.
Why this test matters: Why this test matters:
- Resizer element must be present for drawer width adjustment - Resizer element must be present for drawer width adjustment
- cls "mf-resizer-right": Right-specific resizer for correct edge positioning - cls "mf-resizer-right": Right-specific resizer for correct edge positioning
""" """
layout = Layout(root_instance, app_name="Test App") drawer = find(layout.render(), Div(id=f"{layout._id}_rd"))
drawer = layout._mk_right_drawer()
resizers = find(drawer, Div(cls=Contains("mf-resizer-right"))) resizers = find(drawer, Div(cls=Contains("mf-resizer-right")))
assert len(resizers) == 1, "Right drawer should contain exactly one resizer element" assert len(resizers) == 1, "Right drawer should contain exactly one resizer element"
def test_drawer_groups_are_separated_by_dividers(self, root_instance): def test_drawer_groups_are_separated_by_dividers(self, layout):
"""Test that multiple groups in drawer are separated by divider elements. """Test that multiple groups in drawer are separated by divider elements.
Why this test matters: Why this test matters:
- Dividers provide visual separation between content groups - Dividers provide visual separation between content groups
- At least one divider should exist when multiple groups are present - At least one divider should exist when multiple groups are present
""" """
layout = Layout(root_instance, app_name="Test App")
layout.left_drawer.add(Div("Item 1"), group="group1") layout.left_drawer.add(Div("Item 1"), group="group1")
layout.left_drawer.add(Div("Item 2"), group="group2") layout.left_drawer.add(Div("Item 2"), group="group2")
drawer = layout._mk_left_drawer() drawer = find(layout.render(), Div(id=f"{layout._id}_ld"))
content_wrappers = find(drawer, Div(cls="mf-layout-drawer-content")) content_wrappers = find(drawer, Div(cls="mf-layout-drawer-content"))
assert len(content_wrappers) == 1 assert len(content_wrappers) == 1
@@ -465,18 +454,14 @@ class TestLayoutRender:
dividers = find(content, Div(cls="divider")) dividers = find(content, Div(cls="divider"))
assert len(dividers) >= 1, "Groups should be separated by dividers" assert len(dividers) >= 1, "Groups should be separated by dividers"
def test_resizer_script_is_included(self, root_instance): def test_resizer_script_is_included(self, layout):
"""Test that resizer initialization script is included in render. """Test that resizer initialization script is included in render.
Why this test matters: Why this test matters:
- Script element: Required to initialize resizer functionality - Script element: Required to initialize resizer functionality
- Script contains initResizer call: Ensures resizer is activated for this layout instance - Script contains initResizer call: Ensures resizer is activated for this layout instance
""" """
layout = Layout(root_instance, app_name="Test App") script = find_one(layout.render(), Script())
rendered = layout.render() expected = TestScript(f"initResizer('{layout._id}');")
scripts = find(rendered, Script()) assert matches(script, expected)
assert len(scripts) == 1, "Layout should contain exactly one script element"
script_content = str(scripts[0].children[0])
assert f"initResizer('{layout._id}')" in script_content, "Script must initialize resizer with layout ID"

View File

@@ -1,7 +1,22 @@
import pytest import pytest
from fasthtml.components import Div, Span from fastcore.basics import NotStr
from fasthtml.components import Div, Span, Main
from myfasthtml.test.matcher import find from myfasthtml.test.matcher import find, TestObject, Contains, StartsWith
class Dummy:
def __init__(self, attr1, attr2=None):
self.attr1 = attr1
self.attr2 = attr2
def __eq__(self, other):
return (isinstance(other, Dummy)
and self.attr1 == other.attr1
and self.attr2 == other.attr2)
def __hash__(self):
return hash((self.attr1, self.attr2))
@pytest.mark.parametrize('ft, expected', [ @pytest.mark.parametrize('ft, expected', [
@@ -9,10 +24,13 @@ from myfasthtml.test.matcher import find
(Div(id="id1"), Div(id="id1")), (Div(id="id1"), Div(id="id1")),
(Div(Span(id="span_id"), id="div_id1"), Div(Span(id="span_id"), id="div_id1")), (Div(Span(id="span_id"), id="div_id1"), Div(Span(id="span_id"), id="div_id1")),
(Div(id="id1", id2="id2"), Div(id="id1")), (Div(id="id1", id2="id2"), Div(id="id1")),
(Div(Div(id="id2"), id2="id1"), Div(id="id1")), (Div(Div(id="id2"), id="id1"), Div(id="id1")),
(Dummy(attr1="value"), Dummy(attr1="value")),
(Dummy(attr1="value"), TestObject(Dummy, attr1="value")),
(Div(attr="value1 value2"), Div(attr=Contains("value1"))),
]) ])
def test_i_can_find(ft, expected): def test_i_can_find(ft, expected):
assert find(expected, expected) == [expected] assert find(ft, expected) == [ft]
def test_find_element_by_id_in_a_list(): def test_find_element_by_id_in_a_list():
@@ -25,12 +43,41 @@ def test_find_element_by_id_in_a_list():
def test_i_can_find_sub_element(): def test_i_can_find_sub_element():
a = Div(id="id1") a = Div(id="id1")
b = Div(a, id="id2") b = Span(a, id="id2")
c = Div(b, id="id3") c = Main(b, id="id3")
assert find(c, a) == [a] assert find(c, a) == [a]
def test_i_can_find_when_pattern_appears_also_in_children():
a1 = Div(id="id1")
b = Div(a1, id="id2")
a2 = Div(b, id="id1")
c = Main(a2, id="id3")
assert find(c, a1) == [a2, a1]
@pytest.mark.parametrize('ft, to_search, expected', [
(NotStr("hello"), NotStr("hello"), [NotStr("hello")]),
(NotStr("hello my friend"), NotStr("hello"), NotStr("hello my friend")),
(NotStr("hello"), TestObject(NotStr, s="hello"), [NotStr("hello")]),
(NotStr("hello my friend"), TestObject(NotStr, s=StartsWith("hello")), NotStr("hello my friend")),
])
def test_i_can_manage_notstr_success_path(ft, to_search, expected):
assert find(ft, to_search) == expected
@pytest.mark.parametrize('ft, to_search', [
(NotStr("my friend"), NotStr("hello")),
(NotStr("hello"), Dummy(attr1="hello")), # important, because of the internal __eq__ of NotStr
(NotStr("hello my friend"), TestObject(NotStr, s="hello")),
])
def test_test_i_can_manage_notstr_failure_path(ft, to_search):
with pytest.raises(AssertionError):
find(ft, to_search)
@pytest.mark.parametrize('ft, expected', [ @pytest.mark.parametrize('ft, expected', [
(None, Div(id="id1")), (None, Div(id="id1")),
(Span(id="id1"), Div(id="id1")), (Span(id="id1"), Div(id="id1")),

View File

@@ -2,8 +2,10 @@ import pytest
from fastcore.basics import NotStr from fastcore.basics import NotStr
from fasthtml.components import * from fasthtml.components import *
from myfasthtml.test.matcher import matches, StartsWith, Contains, DoesNotContain, Empty, DoNotCheck, ErrorOutput, \ from myfasthtml.controls.helpers import mk
ErrorComparisonOutput, AttributeForbidden, AnyValue, NoChildren, TestObject from myfasthtml.icons.fluent_p3 import add20_regular
from myfasthtml.test.matcher import matches, StartsWith, Contains, DoesNotContain, Empty, ErrorOutput, \
ErrorComparisonOutput, AttributeForbidden, AnyValue, NoChildren, TestObject, TestIcon, DoNotCheck
from myfasthtml.test.testclient import MyFT from myfasthtml.test.testclient import MyFT
@@ -50,6 +52,9 @@ class TestMatches:
(Dummy(123, "value"), TestObject(Dummy, attr2="value")), (Dummy(123, "value"), TestObject(Dummy, attr2="value")),
(Div(Dummy(123, "value")), Div(TestObject(Dummy, attr1=123))), (Div(Dummy(123, "value")), Div(TestObject(Dummy, attr1=123))),
(Dummy(123, "value"), TestObject("Dummy", attr1=123, attr2="value")), (Dummy(123, "value"), TestObject("Dummy", attr1=123, attr2="value")),
(mk.icon(add20_regular), TestIcon("Add20Regular")),
(mk.icon(add20_regular), TestIcon("add20_regular")),
(mk.icon(add20_regular), TestIcon()),
]) ])
def test_i_can_match(self, actual, expected): def test_i_can_match(self, actual, expected):
assert matches(actual, expected) assert matches(actual, expected)
@@ -93,7 +98,8 @@ class TestMatches:
(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, attr3="value3")), "'attr3' is not found in Actual"),
(Div(Dummy(123, "value")), Div(TestObject(Dummy, attr1=123, attr2="value2")), "are different for 'attr2'"), (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"), (Dummy(123, "value"), TestObject("Dummy", attr1=123, attr2=Contains("value2")),
"The condition 'Contains(value2)' is not satisfied"),
]) ])
def test_i_can_detect_errors(self, actual, expected, error_message): def test_i_can_detect_errors(self, actual, expected, error_message):