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.)
- How to test only what matters (not every detail)
- How to read error messages with `^^^` markers
3. **Apply the best practices**:
- 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)
3. **Apply the best practices** detailed below
---
#### **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:**
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:
- First line: Brief description of what is being tested
- 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)
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
from myfasthtml.test.matcher import matches, find, Contains, NotStr, TestObject
from fasthtml.common import Div, Header, Button
class TestControlRender:
def test_empty_control_is_rendered(self, root_instance):
"""Test that control renders with main structural sections.
@pytest.fixture
def layout(self, root_instance):
return Layout(root_instance, app_name="Test App")
Why these elements matter:
- 3 children: Verifies header, body, and footer are all rendered
- _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)
def test_something(self, layout):
# layout is injected automatically
```
---
#### **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:**
- Reference specific patterns from the documentation
- Explain why you chose to test certain elements and not others
- 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

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):
session_instance = UniqueInstance(session=session, _id=Ids.UserSession)
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")
add_tab = tabs_manager.commands.add_tab

View File

@@ -37,12 +37,13 @@ class Commands(BaseCommands):
def toggle_drawer(self, side: Literal["left", "right"]):
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.
Args:
side: Which drawer to update ("left" or "right")
width: New width in pixels. Given by the HTMX request
Returns:
Command: Command object for updating drawer width
@@ -123,16 +124,6 @@ class Layout(SingleInstance):
self.header_right = self.Content(self)
self.footer_left = 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):
"""
@@ -190,15 +181,17 @@ class Layout(SingleInstance):
return Header(
Div( # left
self._mk_left_drawer_icon(),
*self.header_left.get_content(),
cls="flex gap-1"
*self._mk_content_wrapper(self.header_left, horizontal=True, show_group_name=False).children,
cls="flex gap-1",
id=f"{self._id}_hl"
),
Div( # right
*self.header_right.get_content()[None],
*self._mk_content_wrapper(self.header_right, horizontal=True, show_group_name=False).children,
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):
@@ -208,9 +201,17 @@ class Layout(SingleInstance):
Returns:
Footer: FastHTML Footer component
"""
footer_content = self._footer_content if self._footer_content else ""
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"
)
@@ -299,6 +300,20 @@ class Layout(SingleInstance):
id=f"{self._id}_rdi",
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):
"""
Render the complete layout.

View File

@@ -1,9 +1,11 @@
import re
from dataclasses import dataclass
from typing import Optional
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
MISSING_ATTR = "** MISSING **"
@@ -17,7 +19,18 @@ class Predicate:
raise NotImplementedError
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):
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)
class Contains(AttrPredicate):
class EndsWith(AttrPredicate):
def __init__(self, value):
super().__init__(value)
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):
def __init__(self, value):
def __init__(self, *value):
super().__init__(value)
def validate(self, actual):
return self.value not in actual
return all(val not in actual for val in self.value)
class AnyValue(AttrPredicate):
@@ -75,6 +96,14 @@ class AnyValue(AttrPredicate):
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):
"""
Predicate given as a child of an element.
@@ -122,12 +151,33 @@ class TestObject:
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):
def __init__(self, name, **kwargs):
super().__init__("Command", **kwargs)
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
class DoNotCheck:
desc: str = None
@@ -136,7 +186,7 @@ class DoNotCheck:
def _get_type(x):
if hasattr(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 type(x).__name__
@@ -451,7 +501,7 @@ class Matcher:
# Check if attribute exists
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,
_expected=expected)
@@ -517,7 +567,7 @@ class Matcher:
def _match_notstr(self, actual, expected):
"""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: ",
_actual=to_compare,
_expected=expected.s)
@@ -567,8 +617,9 @@ class Matcher:
return elt.__class__.__name__
@staticmethod
def _str_attrs(attrs: dict):
def _str_attrs(element):
"""Format attributes as a string."""
attrs = _get_attributes(element)
return " ".join(f'"{attr_name}"="{attr_value}"' for attr_name, attr_value in attrs.items())
@staticmethod
@@ -613,13 +664,18 @@ def find(ft, expected):
def _elements_match(actual, expected):
"""Check if two elements are the same based on tag, attributes, and children."""
# Quick equality check
if actual == expected:
if isinstance(expected, DoNotCheck):
return True
# Check if both are FT elements
if not (hasattr(actual, "tag") and hasattr(expected, "tag")):
return False
if isinstance(expected, NotStr):
to_compare = _get_attr(actual, "s").lstrip('\n').lstrip()
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
if _get_type(actual) != _get_type(expected):
@@ -627,11 +683,16 @@ def find(ft, expected):
# Compare attributes
expected_attrs = _get_attributes(expected)
actual_attrs = _get_attributes(actual)
for attr_name, attr_value in expected_attrs.items():
if attr_name not in actual_attrs or actual_attrs[attr_name] != attr_value:
for attr_name, expected_attr_value in expected_attrs.items():
actual_attr_value = _get_attr(actual, attr_name)
# attribute is missing
if actual_attr_value == MISSING_ATTR:
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
expected_children = _get_children(expected)
@@ -646,20 +707,12 @@ def find(ft, expected):
def _search_tree(current, pattern):
"""Recursively search for pattern in the tree rooted at current."""
# Type mismatch - can't be the same
if type(current) != type(pattern):
return []
# For non-FT elements, simple equality check
if not hasattr(current, "tag"):
return [current] if current == pattern else []
# Check if current element matches
matches = []
if _elements_match(current, pattern):
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):
matches.extend(_search_tree(child, pattern))
@@ -680,5 +733,11 @@ def find(ft, expected):
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):
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
from fasthtml.components import *
from myfasthtml.controls.Layout import Layout, LayoutState
from myfasthtml.controls.UserProfile import UserProfile
from myfasthtml.test.matcher import matches, find, Contains, NotStr, TestObject
from myfasthtml.controls.Layout import Layout
from myfasthtml.test.matcher import matches, find, Contains, find_one, TestIcon, TestScript
from .conftest import root_instance
@@ -238,7 +237,11 @@ class TestLayoutBehaviour:
class TestLayoutRender:
"""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.
Why these elements matter:
@@ -246,13 +249,11 @@ class TestLayoutRender:
- _id: Essential for layout identification and resizer initialization
- cls="mf-layout": Root CSS class for layout styling
"""
layout = Layout(root_instance, app_name="Test App")
expected = Div(
Header(),
Div(),
Div(), # left drawer
Main(),
Div(),
Div(), # right drawer
Footer(),
Script(),
_id=layout._id,
@@ -261,26 +262,40 @@ class TestLayoutRender:
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.
Why these elements matter:
- 2 Div children: Left/right header structure for organizing controls
- Svg: Toggle icon is essential for user interaction with drawer
- 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
- Only the first div is required to test the presence of the icon
- Use TestIcon to test the existence of an icon
"""
layout = Layout(root_instance, app_name="Test App")
header = layout._mk_header()
header = find_one(layout.render(), Header(cls="mf-layout-header"))
expected = Header(
Div(
Div(NotStr(name="panel_right_expand20_regular")),
cls="flex gap-1"
),
Div(
TestObject(UserProfile),
TestIcon("panel_right_expand20_regular"),
cls="flex gap-1"
),
cls="mf-layout-header"
@@ -288,59 +303,43 @@ class TestLayoutRender:
assert matches(header, expected)
def test_footer_is_rendered(self, root_instance):
"""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):
def test_main_content_is_rendered_with_some_element(self, layout):
"""Test that main content area renders correctly.
Why these elements matter:
- cls="mf-layout-main": Root main class for styling
"""
layout = Layout(root_instance, app_name="Test App")
main = layout._mk_main()
layout.set_main(Div("Main content"))
main = find_one(layout.render(), Main(cls="mf-layout-main"))
expected = Main(
Div("Main content"),
cls="mf-layout-main"
)
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.
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-left-drawer": Left-specific drawer positioning
- style Contains width: Drawer width must be applied for sizing
"""
layout = Layout(root_instance, app_name="Test App")
drawer = layout._mk_left_drawer()
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"),
#cls=Contains("mf-layout-left-drawer"),
cls=Contains("mf-layout-drawer", "mf-layout-left-drawer"),
style=Contains("width: 250px")
)
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.
Why these elements matter:
@@ -348,9 +347,8 @@ class TestLayoutRender:
- cls Contains "collapsed": Class triggers CSS hiding 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
drawer = layout._mk_left_drawer()
drawer = find_one(layout.render(), Div(id=f"{layout._id}_ld"))
expected = Div(
_id=f"{layout._id}_ld",
@@ -360,7 +358,7 @@ class TestLayoutRender:
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.
Why these elements matter:
@@ -369,19 +367,18 @@ class TestLayoutRender:
- cls Contains "mf-layout-right-drawer": Right-specific drawer positioning
- style Contains width: Drawer width must be applied for sizing
"""
layout = Layout(root_instance, app_name="Test App")
drawer = layout._mk_right_drawer()
layout._state.right_drawer_open = True
drawer = find_one(layout.render(), Div(id=f"{layout._id}_rd"))
expected = Div(
_id=f"{layout._id}_rd",
cls=Contains("mf-layout-drawer"),
#cls=Contains("mf-layout-right-drawer"),
cls=Contains("mf-layout-drawer", "mf-layout-right-drawer"),
style=Contains("width: 250px")
)
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.
Why these elements matter:
@@ -389,9 +386,8 @@ class TestLayoutRender:
- cls Contains "collapsed": Class triggers CSS hiding 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
drawer = layout._mk_right_drawer()
drawer = find_one(layout.render(), Div(id=f"{layout._id}_rd"))
expected = Div(
_id=f"{layout._id}_rd",
@@ -401,15 +397,14 @@ class TestLayoutRender:
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.
Why this test matters:
- 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
drawer = layout._mk_left_drawer()
drawer = find_one(layout.render(), Div(id=f"{layout._id}_ld"))
expected = Div(
style=Contains("width: 300px")
@@ -417,46 +412,40 @@ class TestLayoutRender:
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.
Why this test matters:
- Resizer element must be present for drawer width adjustment
- cls "mf-resizer-left": Left-specific resizer for correct edge positioning
"""
layout = Layout(root_instance, app_name="Test App")
drawer = layout._mk_left_drawer()
drawer = find(layout.render(), Div(id=f"{layout._id}_ld"))
resizers = find(drawer, Div(cls=Contains("mf-resizer-left")))
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.
Why this test matters:
- Resizer element must be present for drawer width adjustment
- cls "mf-resizer-right": Right-specific resizer for correct edge positioning
"""
layout = Layout(root_instance, app_name="Test App")
drawer = layout._mk_right_drawer()
drawer = find(layout.render(), Div(id=f"{layout._id}_rd"))
resizers = find(drawer, Div(cls=Contains("mf-resizer-right")))
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.
Why this test matters:
- Dividers provide visual separation between content groups
- 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 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"))
assert len(content_wrappers) == 1
@@ -465,18 +454,14 @@ class TestLayoutRender:
dividers = find(content, Div(cls="divider"))
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.
Why this test matters:
- Script element: Required to initialize resizer functionality
- Script contains initResizer call: Ensures resizer is activated for this layout instance
"""
layout = Layout(root_instance, app_name="Test App")
rendered = layout.render()
script = find_one(layout.render(), Script())
expected = TestScript(f"initResizer('{layout._id}');")
scripts = find(rendered, Script())
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"
assert matches(script, expected)

View File

@@ -1,7 +1,22 @@
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', [
@@ -9,10 +24,13 @@ from myfasthtml.test.matcher import find
(Div(id="id1"), Div(id="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(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):
assert find(expected, expected) == [expected]
assert find(ft, expected) == [ft]
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():
a = Div(id="id1")
b = Div(a, id="id2")
c = Div(b, id="id3")
b = Span(a, id="id2")
c = Main(b, id="id3")
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', [
(None, Div(id="id1")),
(Span(id="id1"), Div(id="id1")),

View File

@@ -2,8 +2,10 @@ import pytest
from fastcore.basics import NotStr
from fasthtml.components import *
from myfasthtml.test.matcher import matches, StartsWith, Contains, DoesNotContain, Empty, DoNotCheck, ErrorOutput, \
ErrorComparisonOutput, AttributeForbidden, AnyValue, NoChildren, TestObject
from myfasthtml.controls.helpers import mk
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
@@ -50,6 +52,9 @@ class TestMatches:
(Dummy(123, "value"), TestObject(Dummy, attr2="value")),
(Div(Dummy(123, "value")), Div(TestObject(Dummy, attr1=123))),
(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):
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, attr2="value2")), "are different for 'attr2'"),
(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):