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`)
```python
from myfasthtml.test.matcher import matches, find, Contains, NotStr, TestObject
from fasthtml.common import Div, Header, Button
class TestControlRender: 4. **Component testing**: Use `TestObject(ComponentClass)` to test presence of components
def test_empty_control_is_rendered(self, root_instance):
"""Test that control renders with main structural sections.
Why these elements matter: 5. **Test organization for Controls**: Organize tests into thematic classes:
- 3 children: Verifies header, body, and footer are all rendered - `TestControlBehaviour`: Tests for control behavior and logic
- _id: Essential for HTMX targeting and component identification - `TestControlRender`: Tests for control HTML rendering
- cls="control-wrapper": Root CSS class for styling
"""
control = MyControl(root_instance)
expected = Div( 6. **Fixture usage**: In `TestControlRender`, use a pytest fixture to create the control instance:
Header(), ```python
Div(), class TestControlRender:
Div(), @pytest.fixture
_id=control._id, def layout(self, root_instance):
cls="control-wrapper" return Layout(root_instance, app_name="Test App")
)
assert matches(control.render(), expected) def test_something(self, layout):
# layout is injected automatically
```
def test_header_with_icon_is_rendered(self, root_instance): ---
"""Test that header renders with action icon.
Why these elements matter: #### **Résumé : Les 8 règles UTR-11**
- 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( **Pattern fondamental**
Div(NotStr(name="action_icon_20_regular")), - **UTR-11.1** : Pattern en trois étapes (extraire → définir expected → comparer)
TestObject(ActionButton),
cls="header-bar"
)
assert matches(header, expected) **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__
@@ -291,21 +341,21 @@ class ErrorOutput:
element_type = _get_type(element) element_type = _get_type(element)
expected_type = _get_type(expected) expected_type = _get_type(expected)
type_error = (" " if element_type == expected_type else "^") * len(element_type) 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)} 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)} 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 attrs_in_error = {attr_name for attr_name, attr_value in element_attrs.items() if
not self._matches(attr_value, expected_attrs[attr_name])} not self._matches(attr_value, expected_attrs[attr_name])}
attrs_error = " ".join( attrs_error = " ".join(
len(f'"{name}"="{value}"') * ("^" if name in attrs_in_error else " ") len(f'"{name}"="{value}"') * ("^" if name in attrs_in_error else " ")
for name, value in element_attrs.items() for name, value in element_attrs.items()
) )
if type_error.strip() or attrs_error.strip(): if type_error.strip() or attrs_error.strip():
return f" {type_error} {attrs_error}" return f" {type_error} {attrs_error}"
return None return None
# For simple values # For simple values
else: else:
if not self._matches(element, expected): if not self._matches(element, expected):
@@ -435,7 +485,7 @@ class Matcher:
# Validate the type/tag # Validate the type/tag
assert _get_type(actual) == _get_type(expected), \ assert _get_type(actual) == _get_type(expected), \
self._error_msg("The types are different.", _actual=_get_type(actual), _expected=_get_type(expected)) self._error_msg("The types are different.", _actual=_get_type(actual), _expected=_get_type(expected))
# Special conditions (ChildrenPredicate) # Special conditions (ChildrenPredicate)
expected_children = _get_children(expected) expected_children = _get_children(expected)
for predicate in [c for c in expected_children if isinstance(c, ChildrenPredicate)]: for predicate in [c for c in expected_children if isinstance(c, ChildrenPredicate)]:
@@ -443,25 +493,25 @@ class Matcher:
self._error_msg(f"The condition '{predicate}' is not satisfied.", self._error_msg(f"The condition '{predicate}' is not satisfied.",
_actual=actual, _actual=actual,
_expected=predicate.to_debug(expected)) _expected=predicate.to_debug(expected))
# Compare the attributes # Compare the attributes
expected_attrs = _get_attributes(expected) expected_attrs = _get_attributes(expected)
for expected_attr, expected_value in expected_attrs.items(): for expected_attr, expected_value in expected_attrs.items():
actual_value = _get_attr(actual, expected_attr) actual_value = _get_attr(actual, expected_attr)
# 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)
# Handle Predicate values # Handle Predicate values
if isinstance(expected_value, Predicate): if isinstance(expected_value, Predicate):
assert expected_value.validate(actual_value), \ assert expected_value.validate(actual_value), \
self._error_msg(f"The condition '{expected_value}' is not satisfied.", self._error_msg(f"The condition '{expected_value}' is not satisfied.",
_actual=actual, _actual=actual,
_expected=expected) _expected=expected)
# Handle TestObject recursive matching # Handle TestObject recursive matching
elif isinstance(expected, TestObject): elif isinstance(expected, TestObject):
try: try:
@@ -476,23 +526,23 @@ class Matcher:
self._assert_error(f"The values are different for '{expected_attr}'.", self._assert_error(f"The values are different for '{expected_attr}'.",
_actual=actual_value, _actual=actual_value,
_expected=expected_value) _expected=expected_value)
# Handle regular value comparison # Handle regular value comparison
else: else:
assert actual_value == expected_value, \ assert actual_value == expected_value, \
self._error_msg(f"The values are different for '{expected_attr}'.", self._error_msg(f"The values are different for '{expected_attr}'.",
_actual=actual, _actual=actual,
_expected=expected) _expected=expected)
# Compare the children (only if present) # Compare the children (only if present)
if expected_children: if expected_children:
# Filter out Predicate children # Filter out Predicate children
expected_children = [c for c in expected_children if not isinstance(c, Predicate)] expected_children = [c for c in expected_children if not isinstance(c, Predicate)]
actual_children = _get_children(actual) actual_children = _get_children(actual)
if len(actual_children) < len(expected_children): if len(actual_children) < len(expected_children):
self._assert_error("Actual is lesser than expected.", _actual=actual, _expected=expected) self._assert_error("Actual is lesser than expected.", _actual=actual, _expected=expected)
for actual_child, expected_child in zip(actual_children, expected_children): for actual_child, expected_child in zip(actual_children, expected_children):
assert self.matches(actual_child, expected_child) assert self.matches(actual_child, expected_child)
@@ -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
@@ -610,75 +661,83 @@ def find(ft, expected):
Raises: Raises:
AssertionError: If no matching elements are found AssertionError: If no matching elements are found
""" """
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):
return False return False
# 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)
actual_children = _get_children(actual) actual_children = _get_children(actual)
for expected_child in expected_children: for expected_child in expected_children:
# Check if this expected child exists somewhere in actual 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): if not any(_elements_match(actual_child, expected_child) for actual_child in actual_children):
return False return False
return True return True
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))
return matches return matches
# Normalize input to list # Normalize input to list
elements_to_search = ft if isinstance(ft, (list, tuple, set)) else [ft] elements_to_search = ft if isinstance(ft, (list, tuple, set)) else [ft]
# Search in all provided elements # Search in all provided elements
all_matches = [] all_matches = []
for element in elements_to_search: for element in elements_to_search:
all_matches.extend(_search_tree(element, expected)) all_matches.extend(_search_tree(element, expected))
# Raise error if nothing found # Raise error if nothing found
if not all_matches: if not all_matches:
raise AssertionError(f"No element found for '{expected}'") raise AssertionError(f"No element found for '{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
@@ -17,219 +16,219 @@ def cleanup_db():
class TestLayoutBehaviour: class TestLayoutBehaviour:
"""Tests for Layout behavior and logic.""" """Tests for Layout behavior and logic."""
def test_i_can_create_layout(self, root_instance): def test_i_can_create_layout(self, root_instance):
"""Test basic layout creation with app_name.""" """Test basic layout creation with app_name."""
layout = Layout(root_instance, app_name="Test App") layout = Layout(root_instance, app_name="Test App")
assert layout is not None assert layout is not None
assert layout.app_name == "Test App" assert layout.app_name == "Test App"
assert layout._state is not None assert layout._state is not None
def test_i_can_set_main_content(self, root_instance): def test_i_can_set_main_content(self, root_instance):
"""Test setting main content area.""" """Test setting main content area."""
layout = Layout(root_instance, app_name="Test App") layout = Layout(root_instance, app_name="Test App")
content = Div("Main content") content = Div("Main content")
result = layout.set_main(content) result = layout.set_main(content)
assert layout._main_content == content assert layout._main_content == content
assert result == layout # Should return self for chaining assert result == layout # Should return self for chaining
def test_i_can_set_footer_content(self, root_instance): def test_i_can_set_footer_content(self, root_instance):
"""Test setting footer content.""" """Test setting footer content."""
layout = Layout(root_instance, app_name="Test App") layout = Layout(root_instance, app_name="Test App")
content = Div("Footer content") content = Div("Footer content")
layout.set_footer(content) layout.set_footer(content)
assert layout._footer_content == content assert layout._footer_content == content
def test_i_can_add_content_to_left_drawer(self, root_instance): def test_i_can_add_content_to_left_drawer(self, root_instance):
"""Test adding content to left drawer.""" """Test adding content to left drawer."""
layout = Layout(root_instance, app_name="Test App") layout = Layout(root_instance, app_name="Test App")
content = Div("Left drawer content", id="drawer_item") content = Div("Left drawer content", id="drawer_item")
layout.left_drawer.add(content) layout.left_drawer.add(content)
drawer_content = layout.left_drawer.get_content() drawer_content = layout.left_drawer.get_content()
assert None in drawer_content assert None in drawer_content
assert content in drawer_content[None] assert content in drawer_content[None]
def test_i_can_add_content_to_right_drawer(self, root_instance): def test_i_can_add_content_to_right_drawer(self, root_instance):
"""Test adding content to right drawer.""" """Test adding content to right drawer."""
layout = Layout(root_instance, app_name="Test App") layout = Layout(root_instance, app_name="Test App")
content = Div("Right drawer content", id="drawer_item") content = Div("Right drawer content", id="drawer_item")
layout.right_drawer.add(content) layout.right_drawer.add(content)
drawer_content = layout.right_drawer.get_content() drawer_content = layout.right_drawer.get_content()
assert None in drawer_content assert None in drawer_content
assert content in drawer_content[None] assert content in drawer_content[None]
def test_i_can_add_content_to_header_left(self, root_instance): def test_i_can_add_content_to_header_left(self, root_instance):
"""Test adding content to left side of header.""" """Test adding content to left side of header."""
layout = Layout(root_instance, app_name="Test App") layout = Layout(root_instance, app_name="Test App")
content = Div("Header left content", id="header_item") content = Div("Header left content", id="header_item")
layout.header_left.add(content) layout.header_left.add(content)
header_content = layout.header_left.get_content() header_content = layout.header_left.get_content()
assert None in header_content assert None in header_content
assert content in header_content[None] assert content in header_content[None]
def test_i_can_add_content_to_header_right(self, root_instance): def test_i_can_add_content_to_header_right(self, root_instance):
"""Test adding content to right side of header.""" """Test adding content to right side of header."""
layout = Layout(root_instance, app_name="Test App") layout = Layout(root_instance, app_name="Test App")
content = Div("Header right content", id="header_item") content = Div("Header right content", id="header_item")
layout.header_right.add(content) layout.header_right.add(content)
header_content = layout.header_right.get_content() header_content = layout.header_right.get_content()
assert None in header_content assert None in header_content
assert content in header_content[None] assert content in header_content[None]
def test_i_can_add_grouped_content(self, root_instance): def test_i_can_add_grouped_content(self, root_instance):
"""Test adding content with custom groups.""" """Test adding content with custom groups."""
layout = Layout(root_instance, app_name="Test App") layout = Layout(root_instance, app_name="Test App")
group_name = "navigation" group_name = "navigation"
content1 = Div("Nav item 1", id="nav1") content1 = Div("Nav item 1", id="nav1")
content2 = Div("Nav item 2", id="nav2") content2 = Div("Nav item 2", id="nav2")
layout.left_drawer.add(content1, group=group_name) layout.left_drawer.add(content1, group=group_name)
layout.left_drawer.add(content2, group=group_name) layout.left_drawer.add(content2, group=group_name)
drawer_content = layout.left_drawer.get_content() drawer_content = layout.left_drawer.get_content()
assert group_name in drawer_content assert group_name in drawer_content
assert content1 in drawer_content[group_name] assert content1 in drawer_content[group_name]
assert content2 in drawer_content[group_name] assert content2 in drawer_content[group_name]
def test_i_cannot_add_duplicate_content(self, root_instance): def test_i_cannot_add_duplicate_content(self, root_instance):
"""Test that content with same ID is not added twice.""" """Test that content with same ID is not added twice."""
layout = Layout(root_instance, app_name="Test App") layout = Layout(root_instance, app_name="Test App")
content = Div("Content", id="unique_id") content = Div("Content", id="unique_id")
layout.left_drawer.add(content) layout.left_drawer.add(content)
layout.left_drawer.add(content) # Try to add again layout.left_drawer.add(content) # Try to add again
drawer_content = layout.left_drawer.get_content() drawer_content = layout.left_drawer.get_content()
# Content should appear only once # Content should appear only once
assert drawer_content[None].count(content) == 1 assert drawer_content[None].count(content) == 1
def test_i_can_toggle_left_drawer(self, root_instance): def test_i_can_toggle_left_drawer(self, root_instance):
"""Test toggling left drawer open/closed.""" """Test toggling left drawer open/closed."""
layout = Layout(root_instance, app_name="Test App") layout = Layout(root_instance, app_name="Test App")
# Initially open # Initially open
assert layout._state.left_drawer_open is True assert layout._state.left_drawer_open is True
# Toggle to close # Toggle to close
layout.toggle_drawer("left") layout.toggle_drawer("left")
assert layout._state.left_drawer_open is False assert layout._state.left_drawer_open is False
# Toggle to open # Toggle to open
layout.toggle_drawer("left") layout.toggle_drawer("left")
assert layout._state.left_drawer_open is True assert layout._state.left_drawer_open is True
def test_i_can_toggle_right_drawer(self, root_instance): def test_i_can_toggle_right_drawer(self, root_instance):
"""Test toggling right drawer open/closed.""" """Test toggling right drawer open/closed."""
layout = Layout(root_instance, app_name="Test App") layout = Layout(root_instance, app_name="Test App")
# Initially open # Initially open
assert layout._state.right_drawer_open is True assert layout._state.right_drawer_open is True
# Toggle to close # Toggle to close
layout.toggle_drawer("right") layout.toggle_drawer("right")
assert layout._state.right_drawer_open is False assert layout._state.right_drawer_open is False
# Toggle to open # Toggle to open
layout.toggle_drawer("right") layout.toggle_drawer("right")
assert layout._state.right_drawer_open is True assert layout._state.right_drawer_open is True
def test_i_can_update_left_drawer_width(self, root_instance): def test_i_can_update_left_drawer_width(self, root_instance):
"""Test updating left drawer width.""" """Test updating left drawer width."""
layout = Layout(root_instance, app_name="Test App") layout = Layout(root_instance, app_name="Test App")
new_width = 300 new_width = 300
layout.update_drawer_width("left", new_width) layout.update_drawer_width("left", new_width)
assert layout._state.left_drawer_width == new_width assert layout._state.left_drawer_width == new_width
def test_i_can_update_right_drawer_width(self, root_instance): def test_i_can_update_right_drawer_width(self, root_instance):
"""Test updating right drawer width.""" """Test updating right drawer width."""
layout = Layout(root_instance, app_name="Test App") layout = Layout(root_instance, app_name="Test App")
new_width = 400 new_width = 400
layout.update_drawer_width("right", new_width) layout.update_drawer_width("right", new_width)
assert layout._state.right_drawer_width == new_width assert layout._state.right_drawer_width == new_width
def test_i_cannot_set_drawer_width_below_minimum(self, root_instance): def test_i_cannot_set_drawer_width_below_minimum(self, root_instance):
"""Test that drawer width is constrained to minimum 150px.""" """Test that drawer width is constrained to minimum 150px."""
layout = Layout(root_instance, app_name="Test App") layout = Layout(root_instance, app_name="Test App")
layout.update_drawer_width("left", 100) # Try to set below minimum layout.update_drawer_width("left", 100) # Try to set below minimum
assert layout._state.left_drawer_width == 150 # Should be clamped to min assert layout._state.left_drawer_width == 150 # Should be clamped to min
def test_i_cannot_set_drawer_width_above_maximum(self, root_instance): def test_i_cannot_set_drawer_width_above_maximum(self, root_instance):
"""Test that drawer width is constrained to maximum 600px.""" """Test that drawer width is constrained to maximum 600px."""
layout = Layout(root_instance, app_name="Test App") layout = Layout(root_instance, app_name="Test App")
layout.update_drawer_width("right", 800) # Try to set above maximum layout.update_drawer_width("right", 800) # Try to set above maximum
assert layout._state.right_drawer_width == 600 # Should be clamped to max assert layout._state.right_drawer_width == 600 # Should be clamped to max
def test_i_cannot_toggle_invalid_drawer_side(self, root_instance): def test_i_cannot_toggle_invalid_drawer_side(self, root_instance):
"""Test that toggling invalid drawer side raises ValueError.""" """Test that toggling invalid drawer side raises ValueError."""
layout = Layout(root_instance, app_name="Test App") layout = Layout(root_instance, app_name="Test App")
with pytest.raises(ValueError, match="Invalid drawer side"): with pytest.raises(ValueError, match="Invalid drawer side"):
layout.toggle_drawer("invalid") layout.toggle_drawer("invalid")
def test_i_cannot_update_invalid_drawer_width(self, root_instance): def test_i_cannot_update_invalid_drawer_width(self, root_instance):
"""Test that updating invalid drawer side raises ValueError.""" """Test that updating invalid drawer side raises ValueError."""
layout = Layout(root_instance, app_name="Test App") layout = Layout(root_instance, app_name="Test App")
with pytest.raises(ValueError, match="Invalid drawer side"): with pytest.raises(ValueError, match="Invalid drawer side"):
layout.update_drawer_width("invalid", 250) layout.update_drawer_width("invalid", 250)
def test_layout_state_has_correct_defaults(self, root_instance): def test_layout_state_has_correct_defaults(self, root_instance):
"""Test that LayoutState initializes with correct default values.""" """Test that LayoutState initializes with correct default values."""
layout = Layout(root_instance, app_name="Test App") layout = Layout(root_instance, app_name="Test App")
state = layout._state state = layout._state
assert state.left_drawer_open is True assert state.left_drawer_open is True
assert state.right_drawer_open is True assert state.right_drawer_open is True
assert state.left_drawer_width == 250 assert state.left_drawer_width == 250
assert state.right_drawer_width == 250 assert state.right_drawer_width == 250
def test_layout_is_single_instance(self, root_instance): def test_layout_is_single_instance(self, root_instance):
"""Test that Layout behaves as SingleInstance (same ID returns same instance).""" """Test that Layout behaves as SingleInstance (same ID returns same instance)."""
layout1 = Layout(root_instance, app_name="Test App", _id="my_layout") layout1 = Layout(root_instance, app_name="Test App", _id="my_layout")
layout2 = Layout(root_instance, app_name="Test App", _id="my_layout") layout2 = Layout(root_instance, app_name="Test App", _id="my_layout")
# Should be the same instance # Should be the same instance
assert layout1 is layout2 assert layout1 is layout2
def test_commands_are_created(self, root_instance): def test_commands_are_created(self, root_instance):
"""Test that Layout creates necessary commands.""" """Test that Layout creates necessary commands."""
layout = Layout(root_instance, app_name="Test App") layout = Layout(root_instance, app_name="Test App")
# Test toggle commands # Test toggle commands
left_toggle_cmd = layout.commands.toggle_drawer("left") left_toggle_cmd = layout.commands.toggle_drawer("left")
assert left_toggle_cmd is not None assert left_toggle_cmd is not None
assert left_toggle_cmd.id is not None assert left_toggle_cmd.id is not None
right_toggle_cmd = layout.commands.toggle_drawer("right") right_toggle_cmd = layout.commands.toggle_drawer("right")
assert right_toggle_cmd is not None assert right_toggle_cmd is not None
assert right_toggle_cmd.id is not None assert right_toggle_cmd.id is not None
# Test width update commands # Test width update commands
left_width_cmd = layout.commands.update_drawer_width("left") left_width_cmd = layout.commands.update_drawer_width("left")
assert left_width_cmd is not None assert left_width_cmd is not None
assert left_width_cmd.id is not None assert left_width_cmd.id is not None
right_width_cmd = layout.commands.update_drawer_width("right") right_width_cmd = layout.commands.update_drawer_width("right")
assert right_width_cmd is not None assert right_width_cmd is not None
assert right_width_cmd.id is not None assert right_width_cmd.id is not None
@@ -237,8 +236,12 @@ 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,101 +249,97 @@ 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,
cls="mf-layout" cls="mf-layout"
) )
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" cls="flex gap-1"
), ),
Div( cls="mf-layout-header"
TestObject(UserProfile),
cls="flex gap-1"
),
cls="mf-layout-header"
) )
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(
cls="mf-layout-main" Div("Main content"),
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,19 +347,18 @@ 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",
cls=Contains("collapsed"), cls=Contains("collapsed"),
style=Contains("width: 0px") style=Contains("width: 0px")
) )
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,94 +386,82 @@ 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",
cls=Contains("collapsed"), cls=Contains("collapsed"),
style=Contains("width: 0px") style=Contains("width: 0px")
) )
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")
) )
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
content = content_wrappers[0] content = content_wrappers[0]
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):