Compare commits
6 Commits
master
...
e96ac5ddfd
| Author | SHA1 | Date | |
|---|---|---|---|
| e96ac5ddfd | |||
| 378775cdf9 | |||
| e34d675e38 | |||
| 93cb477c21 | |||
| 96ed447eae | |||
| 1be75263ad |
@@ -199,7 +199,313 @@ class TestControlRender:
|
||||
|
||||
**Note:** This organization applies **only to controls** (components with rendering capabilities). For other classes (core logic, utilities, etc.), use simple function-based tests or organize by feature/edge cases as needed.
|
||||
|
||||
### UTR-11: Test Workflow
|
||||
### UTR-11: Required Reading for Control Render Tests
|
||||
|
||||
**Before writing ANY render tests for Controls, you MUST:**
|
||||
|
||||
1. **Read the matcher documentation**: `docs/testing_rendered_components.md`
|
||||
2. **Understand the key concepts**:
|
||||
- How `matches()` and `find()` work
|
||||
- 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** 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
|
||||
- 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 (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
|
||||
class TestControlRender:
|
||||
@pytest.fixture
|
||||
def layout(self, root_instance):
|
||||
return Layout(root_instance, app_name="Test App")
|
||||
|
||||
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 justification documentation (see UTR-11.7)
|
||||
|
||||
### UTR-12: Test Workflow
|
||||
|
||||
1. **Receive code to test** - User provides file path or code section
|
||||
2. **Check existing tests** - Look for corresponding test file and read it if it exists
|
||||
|
||||
53
docs/Layout.md
Normal file
53
docs/Layout.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# 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
|
||||
```
|
||||
Div(id="layout")
|
||||
├── Header
|
||||
│ ├── Div(id="layout_hl")
|
||||
│ │ ├── Icon # Left drawer icon button
|
||||
│ │ └── Div # Left content for the header
|
||||
│ └── Div(id="layout_hr")
|
||||
│ ├── Div # Right content for the header
|
||||
│ └── UserProfile # user profile icon button
|
||||
├── Div # Left Drawer
|
||||
├── Main # Main content
|
||||
├── Div # Right Drawer
|
||||
├── Footer # Footer
|
||||
└── Script # To initialize the resizing
|
||||
```
|
||||
895
docs/testing_rendered_components.md
Normal file
895
docs/testing_rendered_components.md
Normal file
@@ -0,0 +1,895 @@
|
||||
# Testing Rendered Components with Matcher
|
||||
|
||||
## Introduction
|
||||
|
||||
When testing FastHTML components, you need to verify that the HTML they generate is correct. Traditional approaches like string comparison are fragile and hard to maintain. The matcher module provides two powerful functions that make component testing simple and reliable:
|
||||
|
||||
- **`matches(actual, expected)`** - Validates that a rendered element matches your expectations
|
||||
- **`find(ft, expected)`** - Searches for specific elements within an HTML tree
|
||||
|
||||
**Key principle**: Test only what matters. The matcher compares only the elements and attributes you explicitly define in your `expected` pattern, ignoring everything else.
|
||||
|
||||
### Why use matcher?
|
||||
|
||||
**Without matcher:**
|
||||
```python
|
||||
# Fragile - breaks if whitespace or attribute order changes
|
||||
assert str(component.render()) == '<div id="x" class="y"><p>Text</p></div>'
|
||||
```
|
||||
|
||||
**With matcher:**
|
||||
```python
|
||||
# Robust - tests only what matters
|
||||
from myfasthtml.test.matcher import matches
|
||||
from fasthtml.common import Div, P
|
||||
|
||||
actual = component.render()
|
||||
expected = Div(P("Text"))
|
||||
matches(actual, expected) # Passes - ignores id and class
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Part 1: Function Reference
|
||||
|
||||
### matches() - Validate Elements
|
||||
|
||||
#### Purpose
|
||||
|
||||
`matches()` validates that a rendered element structure corresponds exactly to an expected pattern. It's the primary tool for testing component rendering.
|
||||
|
||||
**When to use it:**
|
||||
- Verifying component output in tests
|
||||
- Checking HTML structure
|
||||
- Validating attributes and content
|
||||
|
||||
#### Basic Syntax
|
||||
|
||||
```python
|
||||
from myfasthtml.test.matcher import matches
|
||||
|
||||
matches(actual, expected)
|
||||
```
|
||||
|
||||
- **`actual`**: The element to test (usually from `component.render()`)
|
||||
- **`expected`**: The pattern to match against (only include what you want to test)
|
||||
- **Returns**: `True` if matches, raises `AssertionError` if not
|
||||
|
||||
#### Simple Examples
|
||||
|
||||
**Example 1: Basic structure matching**
|
||||
|
||||
```python
|
||||
from myfasthtml.test.matcher import matches
|
||||
from fasthtml.common import Div, P
|
||||
|
||||
# The actual rendered element
|
||||
actual = Div(P("Hello World"), id="container", cls="main")
|
||||
|
||||
# Expected pattern - tests only the structure
|
||||
expected = Div(P("Hello World"))
|
||||
|
||||
matches(actual, expected) # ✅ Passes - id and cls are ignored
|
||||
```
|
||||
|
||||
**Example 2: Testing specific attributes**
|
||||
|
||||
```python
|
||||
from fasthtml.common import Button
|
||||
|
||||
actual = Button("Click me",
|
||||
id="btn-1",
|
||||
cls="btn btn-primary",
|
||||
hx_post="/submit",
|
||||
hx_target="#result")
|
||||
|
||||
# Test only the HTMX attribute we care about
|
||||
expected = Button("Click me", hx_post="/submit")
|
||||
|
||||
matches(actual, expected) # ✅ Passes
|
||||
```
|
||||
|
||||
**Example 3: Nested structure**
|
||||
|
||||
```python
|
||||
from fasthtml.common import Div, H1, Form, Input, Button
|
||||
|
||||
actual = Div(
|
||||
H1("Registration Form"),
|
||||
Form(
|
||||
Input(name="email", type="email"),
|
||||
Input(name="password", type="password"),
|
||||
Button("Submit", type="submit")
|
||||
),
|
||||
id="page",
|
||||
cls="container"
|
||||
)
|
||||
|
||||
# Test only the important parts
|
||||
expected = Div(
|
||||
H1("Registration Form"),
|
||||
Form(
|
||||
Input(name="email"),
|
||||
Button("Submit")
|
||||
)
|
||||
)
|
||||
|
||||
matches(actual, expected) # ✅ Passes - ignores password field and attributes
|
||||
```
|
||||
|
||||
#### Predicates Reference
|
||||
|
||||
Predicates allow flexible validation when you don't know the exact value but want to validate a pattern.
|
||||
|
||||
##### AttrPredicate - For attribute values
|
||||
|
||||
**Contains(value)** - Attribute contains the value
|
||||
|
||||
```python
|
||||
from myfasthtml.test.matcher import Contains
|
||||
from fasthtml.common import Div
|
||||
|
||||
actual = Div(cls="container main-content active")
|
||||
expected = Div(cls=Contains("main-content"))
|
||||
|
||||
matches(actual, expected) # ✅ Passes
|
||||
```
|
||||
|
||||
**StartsWith(value)** - Attribute starts with the value
|
||||
|
||||
```python
|
||||
from myfasthtml.test.matcher import StartsWith
|
||||
from fasthtml.common import Input
|
||||
|
||||
actual = Input(id="input-username-12345")
|
||||
expected = Input(id=StartsWith("input-username"))
|
||||
|
||||
matches(actual, expected) # ✅ Passes
|
||||
```
|
||||
|
||||
**DoesNotContain(value)** - Attribute does not contain the value
|
||||
|
||||
```python
|
||||
from myfasthtml.test.matcher import DoesNotContain
|
||||
from fasthtml.common import Div
|
||||
|
||||
actual = Div(cls="container active")
|
||||
expected = Div(cls=DoesNotContain("disabled"))
|
||||
|
||||
matches(actual, expected) # ✅ Passes
|
||||
```
|
||||
|
||||
**AnyValue()** - Attribute exists with any non-None value
|
||||
|
||||
```python
|
||||
from myfasthtml.test.matcher import AnyValue
|
||||
from fasthtml.common import Button
|
||||
|
||||
actual = Button("Click", data_action="submit-form", data_id="123")
|
||||
expected = Button("Click", data_action=AnyValue())
|
||||
|
||||
matches(actual, expected) # ✅ Passes - just checks data_action exists
|
||||
```
|
||||
|
||||
##### ChildrenPredicate - For element children
|
||||
|
||||
**Empty()** - Element has no children and no attributes
|
||||
|
||||
```python
|
||||
from myfasthtml.test.matcher import Empty
|
||||
from fasthtml.common import Div
|
||||
|
||||
actual = Div()
|
||||
expected = Div(Empty())
|
||||
|
||||
matches(actual, expected) # ✅ Passes
|
||||
```
|
||||
|
||||
**NoChildren()** - Element has no children (but can have attributes)
|
||||
|
||||
```python
|
||||
from myfasthtml.test.matcher import NoChildren
|
||||
from fasthtml.common import Div
|
||||
|
||||
actual = Div(id="container", cls="empty")
|
||||
expected = Div(NoChildren())
|
||||
|
||||
matches(actual, expected) # ✅ Passes - has attributes but no children
|
||||
```
|
||||
|
||||
**AttributeForbidden(attr_name)** - Attribute must not be present
|
||||
|
||||
```python
|
||||
from myfasthtml.test.matcher import AttributeForbidden
|
||||
from fasthtml.common import Button
|
||||
|
||||
actual = Button("Click me")
|
||||
expected = Button("Click me", AttributeForbidden("disabled"))
|
||||
|
||||
matches(actual, expected) # ✅ Passes - disabled attribute is not present
|
||||
```
|
||||
|
||||
#### Error Messages Explained
|
||||
|
||||
When a test fails, `matches()` provides a visual diff showing exactly where the problem is:
|
||||
|
||||
```python
|
||||
from fasthtml.common import Div, Button
|
||||
|
||||
actual = Div(Button("Submit", cls="btn-primary"), id="form")
|
||||
expected = Div(Button("Cancel", cls="btn-secondary"))
|
||||
|
||||
matches(actual, expected)
|
||||
```
|
||||
|
||||
**Error output:**
|
||||
```
|
||||
Path : 'div.button'
|
||||
Error : The values are different
|
||||
|
||||
(div "id"="form" | (div
|
||||
(button "cls"="btn-prim | (button "cls"="btn-seco
|
||||
^^^^^^^^^^^^^^^^ |
|
||||
"Submit") | "Cancel")
|
||||
^^^^^^^ |
|
||||
) | )
|
||||
```
|
||||
|
||||
**Reading the error:**
|
||||
- **Left side**: Actual element
|
||||
- **Right side**: Expected pattern
|
||||
- **`^^^` markers**: Highlight differences 'only on the left side', the right side (the expected pattern) is always correct
|
||||
- **Path**: Shows location in the tree (`div.button` = button inside div)
|
||||
|
||||
---
|
||||
|
||||
### find() - Search Elements
|
||||
|
||||
#### Purpose
|
||||
|
||||
`find()` searches for all elements matching a pattern within an HTML tree. It's useful when you need to verify the presence of specific elements without knowing their exact position. Or when you want to validate (using matches) a subset of elements.
|
||||
|
||||
**When to use it:**
|
||||
- Finding elements by attributes
|
||||
- Verifying element count
|
||||
- Extracting elements for further validation
|
||||
- Testing without strict hierarchy requirements
|
||||
|
||||
#### Basic Syntax
|
||||
|
||||
```python
|
||||
from myfasthtml.test.matcher import find
|
||||
|
||||
results = find(ft, expected)
|
||||
```
|
||||
|
||||
- **`ft`**: Element or list of elements to search in
|
||||
- **`expected`**: Pattern to match, follows the same syntax and rules as `matches()`
|
||||
- **Returns**: List of all matching elements
|
||||
- **Raises**: `AssertionError` if no matches found
|
||||
|
||||
#### Simple Examples
|
||||
|
||||
**Example 1: Find all elements of a type**
|
||||
|
||||
```python
|
||||
from myfasthtml.test.matcher import find
|
||||
from fasthtml.common import Div, P
|
||||
|
||||
page = Div(
|
||||
Div(P("First paragraph")),
|
||||
Div(P("Second paragraph")),
|
||||
P("Third paragraph")
|
||||
)
|
||||
|
||||
# Find all paragraphs
|
||||
paragraphs = find(page, P())
|
||||
|
||||
assert len(paragraphs) == 3
|
||||
```
|
||||
|
||||
**Example 2: Find by attribute**
|
||||
|
||||
```python
|
||||
from fasthtml.common import Div, Button
|
||||
from myfasthtml.test.matcher import find
|
||||
|
||||
page = Div(
|
||||
Button("Cancel", cls="btn-secondary"),
|
||||
Div(Button("Submit", cls="btn-primary", id="submit"))
|
||||
)
|
||||
|
||||
# Find the primary button
|
||||
primary_buttons = find(page, Button(cls="btn-primary"))
|
||||
|
||||
assert len(primary_buttons) == 1
|
||||
assert primary_buttons[0].attrs["id"] == "submit"
|
||||
```
|
||||
|
||||
**Example 3: Find nested structure**
|
||||
|
||||
```python
|
||||
from fasthtml.common import Div, Form, Input
|
||||
|
||||
page = Div(
|
||||
Div(Input(name="search")),
|
||||
Form(
|
||||
Input(name="email", type="email"),
|
||||
Input(name="password", type="password")
|
||||
)
|
||||
)
|
||||
|
||||
# Find all email inputs
|
||||
email_inputs = find(page, Input(type="email"))
|
||||
|
||||
assert len(email_inputs) == 1
|
||||
assert email_inputs[0].attrs["name"] == "email"
|
||||
```
|
||||
|
||||
**Example 4: Search in a list**
|
||||
|
||||
```python
|
||||
from fasthtml.common import Div, P, Span
|
||||
|
||||
elements = [
|
||||
Div(P("First")),
|
||||
Div(P("Second")),
|
||||
Span(P("Third"))
|
||||
]
|
||||
|
||||
# Find all paragraphs across all elements
|
||||
all_paragraphs = find(elements, P())
|
||||
|
||||
assert len(all_paragraphs) == 3
|
||||
```
|
||||
|
||||
#### Common Patterns
|
||||
|
||||
**Verify element count:**
|
||||
|
||||
```python
|
||||
buttons = find(page, Button())
|
||||
assert len(buttons) == 3, f"Expected 3 buttons, found {len(buttons)}"
|
||||
```
|
||||
|
||||
**Check element exists:**
|
||||
|
||||
```python
|
||||
submit_buttons = find(page, Button(type="submit"))
|
||||
assert len(submit_buttons) > 0, "No submit button found"
|
||||
```
|
||||
|
||||
**Extract for further testing:**
|
||||
|
||||
```python
|
||||
form = find(page, Form())[0] # Get first form
|
||||
inputs = find(form, Input()) # Find inputs within form
|
||||
assert all(inp.attrs.get("type") in ["text", "email"] for inp in inputs)
|
||||
```
|
||||
|
||||
**Handle missing elements:**
|
||||
|
||||
```python
|
||||
try:
|
||||
admin_section = find(page, Div(id="admin"))
|
||||
print("Admin section found")
|
||||
except AssertionError:
|
||||
print("Admin section not present")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Part 2: Testing Rendered Components Guide
|
||||
|
||||
This section provides practical patterns for testing different aspects of rendered components.
|
||||
|
||||
### 1. Testing Element Structure
|
||||
|
||||
**Goal**: Verify the hierarchy and organization of elements.
|
||||
|
||||
**Pattern**: Use `matches()` with only the structural elements you care about.
|
||||
|
||||
```python
|
||||
from myfasthtml.test.matcher import matches
|
||||
from fasthtml.common import Div, Header, Nav, Main, Footer
|
||||
|
||||
# Your component renders a page layout
|
||||
actual = page_component.render()
|
||||
|
||||
# Test only the main structure
|
||||
expected = Div(
|
||||
Header(Nav()),
|
||||
Main(),
|
||||
Footer()
|
||||
)
|
||||
|
||||
matches(actual, expected)
|
||||
```
|
||||
|
||||
**Tip**: Don't include every single child element. Focus on the important structural elements.
|
||||
|
||||
### 2. Testing Attributes
|
||||
|
||||
**Goal**: Verify that specific attributes are set correctly.
|
||||
|
||||
**Pattern**: Include only the attributes you want to test.
|
||||
|
||||
```python
|
||||
from fasthtml.common import Button
|
||||
|
||||
# Component renders a button with HTMX
|
||||
actual = button_component.render()
|
||||
|
||||
# Test only HTMX attributes
|
||||
expected = Button(
|
||||
hx_post="/api/submit",
|
||||
hx_target="#result",
|
||||
hx_swap="innerHTML"
|
||||
)
|
||||
|
||||
matches(actual, expected)
|
||||
```
|
||||
|
||||
**With predicates for dynamic values:**
|
||||
|
||||
```python
|
||||
from myfasthtml.test.matcher import StartsWith
|
||||
|
||||
# Test that id follows a pattern
|
||||
expected = Button(id=StartsWith("btn-"))
|
||||
matches(actual, expected)
|
||||
```
|
||||
|
||||
### 3. Testing Content
|
||||
|
||||
**Goal**: Verify text content in elements.
|
||||
|
||||
**Pattern**: Match elements with their text content.
|
||||
|
||||
```python
|
||||
from fasthtml.common import Div, H1, P
|
||||
|
||||
actual = article_component.render()
|
||||
|
||||
expected = Div(
|
||||
H1("Article Title"),
|
||||
P("First paragraph content")
|
||||
)
|
||||
|
||||
matches(actual, expected)
|
||||
```
|
||||
|
||||
**Partial content matching:**
|
||||
|
||||
```python
|
||||
from myfasthtml.test.matcher import Contains
|
||||
|
||||
# Check that paragraph contains key phrase
|
||||
expected = P(Contains("important information"))
|
||||
matches(actual, expected)
|
||||
```
|
||||
|
||||
### 4. Testing with Predicates
|
||||
|
||||
**Goal**: Validate patterns rather than exact values.
|
||||
|
||||
**Pattern**: Use predicates for flexibility.
|
||||
|
||||
**Example: Testing generated IDs**
|
||||
|
||||
```python
|
||||
from myfasthtml.test.matcher import StartsWith, AnyValue
|
||||
from fasthtml.common import Div
|
||||
|
||||
# Component generates unique IDs
|
||||
actual = widget_component.render()
|
||||
|
||||
expected = Div(
|
||||
id=StartsWith("widget-"),
|
||||
data_timestamp=AnyValue() # Just check it exists
|
||||
)
|
||||
|
||||
matches(actual, expected)
|
||||
```
|
||||
|
||||
**Example: Testing CSS classes**
|
||||
|
||||
```python
|
||||
from myfasthtml.test.matcher import Contains
|
||||
from fasthtml.common import Button
|
||||
|
||||
actual = dynamic_button.render()
|
||||
|
||||
# Check button has 'active' class among others
|
||||
expected = Button(cls=Contains("active"))
|
||||
matches(actual, expected)
|
||||
```
|
||||
|
||||
**Example: Forbidden attributes**
|
||||
|
||||
```python
|
||||
from myfasthtml.test.matcher import AttributeForbidden
|
||||
from fasthtml.common import Input
|
||||
|
||||
# Verify input is NOT disabled
|
||||
actual = input_component.render()
|
||||
|
||||
expected = Input(
|
||||
name="username",
|
||||
AttributeForbidden("disabled")
|
||||
)
|
||||
|
||||
matches(actual, expected)
|
||||
```
|
||||
|
||||
### 5. Combining matches() and find()
|
||||
|
||||
**Goal**: First find elements, then validate them in detail.
|
||||
|
||||
**Pattern**: Use `find()` to locate, then `matches()` to validate.
|
||||
|
||||
**Example: Testing a form**
|
||||
|
||||
```python
|
||||
from myfasthtml.test.matcher import find, matches
|
||||
from fasthtml.common import Form, Input, Button
|
||||
|
||||
# Render a complex page
|
||||
actual = page_component.render()
|
||||
|
||||
# Step 1: Find the registration form
|
||||
forms = find(actual, Form(id="registration"))
|
||||
assert len(forms) == 1
|
||||
|
||||
# Step 2: Validate the form structure
|
||||
registration_form = forms[0]
|
||||
expected_form = Form(
|
||||
Input(name="email", type="email"),
|
||||
Input(name="password", type="password"),
|
||||
Button("Register", type="submit")
|
||||
)
|
||||
|
||||
matches(registration_form, expected_form)
|
||||
```
|
||||
|
||||
**Example: Testing multiple similar elements**
|
||||
|
||||
```python
|
||||
from fasthtml.common import Div, Card
|
||||
|
||||
actual = dashboard.render()
|
||||
|
||||
# Find all cards
|
||||
cards = find(actual, Card())
|
||||
|
||||
# Verify we have the right number
|
||||
assert len(cards) == 3
|
||||
|
||||
# Validate each card has required structure
|
||||
for card in cards:
|
||||
expected_card = Card(
|
||||
Div(cls="card-header"),
|
||||
Div(cls="card-body")
|
||||
)
|
||||
matches(card, expected_card)
|
||||
```
|
||||
|
||||
### 6. Testing Edge Cases
|
||||
|
||||
**Testing empty elements:**
|
||||
|
||||
```python
|
||||
from myfasthtml.test.matcher import Empty, NoChildren
|
||||
from fasthtml.common import Div
|
||||
|
||||
# Test completely empty element
|
||||
actual = Div()
|
||||
expected = Div(Empty())
|
||||
matches(actual, expected)
|
||||
|
||||
# Test element with attributes but no children
|
||||
actual = Div(id="container", cls="empty-state")
|
||||
expected = Div(NoChildren())
|
||||
matches(actual, expected)
|
||||
```
|
||||
|
||||
**Testing absence of elements:**
|
||||
|
||||
```python
|
||||
from myfasthtml.test.matcher import find
|
||||
from fasthtml.common import Div
|
||||
|
||||
actual = basic_view.render()
|
||||
|
||||
# Verify admin section is not present
|
||||
try:
|
||||
find(actual, Div(id="admin-section"))
|
||||
assert False, "Admin section should not be present"
|
||||
except AssertionError as e:
|
||||
if "No element found" in str(e):
|
||||
pass # Expected - admin section absent
|
||||
else:
|
||||
raise
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Part 3: How It Works (Technical Overview)
|
||||
|
||||
Understanding how the matcher works helps you write better tests and debug failures more effectively.
|
||||
|
||||
### The Matching Algorithm
|
||||
|
||||
When you call `matches(actual, expected)`, here's what happens:
|
||||
|
||||
1. **Type Comparison**: Checks if elements have the same type/tag
|
||||
- For FastHTML elements: compares `.tag` (e.g., "div", "button")
|
||||
- For Python objects: compares class types
|
||||
|
||||
2. **Attribute Validation**: For each attribute in `expected`:
|
||||
- Checks if attribute exists in `actual`
|
||||
- If it's a `Predicate`: calls `.validate(actual_value)`
|
||||
- Otherwise: checks for exact equality
|
||||
- **Key point**: Only attributes in `expected` are checked
|
||||
|
||||
3. **Children Validation**:
|
||||
- Applies `ChildrenPredicate` validators if present
|
||||
- Recursively matches children in order
|
||||
- **Key point**: Only checks as many children as defined in `expected`
|
||||
|
||||
4. **Path Tracking**: Maintains a path through the tree for error reporting
|
||||
|
||||
### Understanding the Path
|
||||
|
||||
The matcher builds a path as it traverses your element tree:
|
||||
|
||||
```
|
||||
div#container.form.input[name=email]
|
||||
```
|
||||
|
||||
This path means:
|
||||
- Started at a `div` with `id="container"`
|
||||
- Went to a `form` element
|
||||
- Then to an `input` with `name="email"`
|
||||
|
||||
The path appears in error messages to help you locate problems:
|
||||
|
||||
```
|
||||
Path : 'div#form.input[name=email]'
|
||||
Error : 'type' is not found in Actual
|
||||
```
|
||||
|
||||
### How Predicates Work
|
||||
|
||||
Predicates are objects that implement a `validate()` method:
|
||||
|
||||
```python
|
||||
class Contains(AttrPredicate):
|
||||
def validate(self, actual):
|
||||
return self.value in actual
|
||||
```
|
||||
|
||||
When matching encounters a predicate:
|
||||
1. Gets the actual attribute value
|
||||
2. Calls `predicate.validate(actual_value)`
|
||||
3. If returns `False`, reports validation error
|
||||
|
||||
This allows flexible matching without hardcoding exact values.
|
||||
|
||||
### Error Output Generation
|
||||
|
||||
When a test fails, the matcher generates a side-by-side comparison:
|
||||
|
||||
**Process:**
|
||||
1. Renders both `actual` and `expected` as tree structures
|
||||
2. Compares them element by element
|
||||
3. Marks differences with `^^^` characters
|
||||
4. Aligns output for easy visual comparison
|
||||
|
||||
**Example:**
|
||||
```
|
||||
(div "id"="old" | (div "id"="new"
|
||||
^^^^ |
|
||||
```
|
||||
|
||||
The `^^^` appears under attributes or content that don't match.
|
||||
|
||||
### The find() Algorithm
|
||||
|
||||
`find()` uses depth-first search:
|
||||
|
||||
1. **Check current element**: Does it match the pattern?
|
||||
- If yes: add to results
|
||||
|
||||
2. **Search children**: Recursively search all children
|
||||
|
||||
3. **Return all matches**: Collects matches from entire tree
|
||||
|
||||
**Key difference from matches()**: `find()` looks for any occurrence anywhere in the tree, while `matches()` validates exact structure.
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Do's ✅
|
||||
|
||||
**Test only what matters**
|
||||
```python
|
||||
# ✅ Good - tests only the submit action
|
||||
expected = Button("Submit", hx_post="/api/save")
|
||||
|
||||
# ❌ Bad - tests irrelevant details
|
||||
expected = Button("Submit", hx_post="/api/save", id="btn-123", cls="btn btn-primary")
|
||||
```
|
||||
|
||||
**Use predicates for dynamic values**
|
||||
```python
|
||||
# ✅ Good - flexible for generated IDs
|
||||
expected = Div(id=StartsWith("generated-"))
|
||||
|
||||
# ❌ Bad - brittle, will break on regeneration
|
||||
expected = Div(id="generated-12345")
|
||||
```
|
||||
|
||||
**Structure tests in layers**
|
||||
```python
|
||||
# ✅ Good - separate concerns
|
||||
# Test 1: Overall structure
|
||||
matches(page, Div(Header(), Main(), Footer()))
|
||||
|
||||
# Test 2: Header details
|
||||
header = find(page, Header())[0]
|
||||
matches(header, Header(Nav(), Div(cls="user-menu")))
|
||||
|
||||
# ❌ Bad - everything in one giant test
|
||||
matches(page, Div(
|
||||
Header(Nav(...), Div(...)),
|
||||
Main(...),
|
||||
Footer(...)
|
||||
))
|
||||
```
|
||||
|
||||
**Verify element counts with find()**
|
||||
```python
|
||||
# ✅ Good - explicit count check
|
||||
buttons = find(page, Button())
|
||||
assert len(buttons) == 3, f"Expected 3 buttons, found {len(buttons)}"
|
||||
|
||||
# ❌ Bad - implicit assumption
|
||||
button = find(page, Button())[0] # Fails if 0 or multiple buttons
|
||||
```
|
||||
|
||||
### Don'ts ❌
|
||||
|
||||
**Don't test implementation details**
|
||||
```python
|
||||
# ❌ Bad - internal div structure might change
|
||||
expected = Div(
|
||||
Div(
|
||||
Div(Button("Click"))
|
||||
)
|
||||
)
|
||||
|
||||
# ✅ Good - test the important element
|
||||
expected = Div(Button("Click"))
|
||||
```
|
||||
|
||||
**Don't use exact string matching**
|
||||
```python
|
||||
# ❌ Bad - fragile
|
||||
assert str(component.render()) == '<div><p>Text</p></div>'
|
||||
|
||||
# ✅ Good - structural matching
|
||||
matches(component.render(), Div(P("Text")))
|
||||
```
|
||||
|
||||
**Don't over-specify**
|
||||
```python
|
||||
# ❌ Bad - tests too much
|
||||
expected = Form(
|
||||
Input(name="email", type="email", id="email-input", cls="form-control"),
|
||||
Input(name="password", type="password", id="pwd-input", cls="form-control"),
|
||||
Button("Submit", type="submit", id="submit-btn", cls="btn btn-primary")
|
||||
)
|
||||
|
||||
# ✅ Good - tests what matters
|
||||
expected = Form(
|
||||
Input(name="email", type="email"),
|
||||
Input(name="password", type="password"),
|
||||
Button("Submit", type="submit")
|
||||
)
|
||||
```
|
||||
|
||||
### Performance Tips
|
||||
|
||||
**Reuse patterns**
|
||||
```python
|
||||
# Define reusable patterns
|
||||
STANDARD_BUTTON = Button(cls=Contains("btn"))
|
||||
|
||||
# Use in multiple tests
|
||||
matches(actual, Div(STANDARD_BUTTON))
|
||||
```
|
||||
|
||||
**Use find() efficiently**
|
||||
```python
|
||||
# ✅ Good - specific pattern
|
||||
buttons = find(page, Button(cls="primary"))
|
||||
|
||||
# ❌ Inefficient - too broad then filter
|
||||
all_buttons = find(page, Button())
|
||||
primary = [b for b in all_buttons if "primary" in b.attrs.get("cls", "")]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Import Statements
|
||||
|
||||
```python
|
||||
# Core functions
|
||||
from myfasthtml.test.matcher import matches, find
|
||||
|
||||
# AttrPredicates
|
||||
from myfasthtml.test.matcher import Contains, StartsWith, DoesNotContain, AnyValue
|
||||
|
||||
# ChildrenPredicates
|
||||
from myfasthtml.test.matcher import Empty, NoChildren, AttributeForbidden
|
||||
|
||||
# For custom test objects
|
||||
from myfasthtml.test.matcher import TestObject
|
||||
```
|
||||
|
||||
### Common Patterns Cheatsheet
|
||||
|
||||
```python
|
||||
# Basic validation
|
||||
matches(actual, expected)
|
||||
|
||||
# Find and validate
|
||||
elements = find(tree, pattern)
|
||||
assert len(elements) == 1
|
||||
matches(elements[0], detailed_pattern)
|
||||
|
||||
# Flexible attribute matching
|
||||
expected = Div(
|
||||
id=StartsWith("prefix-"),
|
||||
cls=Contains("active"),
|
||||
data_value=AnyValue()
|
||||
)
|
||||
|
||||
# Empty element validation
|
||||
expected = Div(Empty()) # No children, no attributes
|
||||
expected = Div(NoChildren()) # No children, attributes OK
|
||||
|
||||
# Forbidden attribute
|
||||
expected = Button(AttributeForbidden("disabled"))
|
||||
|
||||
# Multiple children
|
||||
expected = Div(
|
||||
Header(),
|
||||
Main(),
|
||||
Footer()
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Conclusion
|
||||
|
||||
The matcher module provides powerful tools for testing FastHTML components:
|
||||
|
||||
- **`matches()`** validates structure with precise, readable error messages
|
||||
- **`find()`** locates elements anywhere in your HTML tree
|
||||
- **Predicates** enable flexible, maintainable tests
|
||||
|
||||
By focusing on what matters and using the right patterns, you can write component tests that are both robust and easy to maintain.
|
||||
|
||||
**Next steps:**
|
||||
- Practice with simple components first
|
||||
- Gradually introduce predicates as needed
|
||||
- Review error messages carefully - they guide you to the problem
|
||||
- Refactor tests to remove duplication
|
||||
|
||||
Happy testing! 🧪
|
||||
@@ -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
|
||||
|
||||
@@ -5,6 +5,10 @@ from myfasthtml.core.network_utils import from_parent_child_list
|
||||
|
||||
|
||||
class CommandsDebugger(SingleInstance):
|
||||
"""
|
||||
Represents a debugger designed for visualizing and managing commands in a parent-child
|
||||
hierarchical structure.
|
||||
"""
|
||||
def __init__(self, parent, _id=None):
|
||||
super().__init__(parent, _id=_id)
|
||||
|
||||
|
||||
@@ -22,6 +22,12 @@ class DropdownState:
|
||||
|
||||
|
||||
class Dropdown(MultipleInstance):
|
||||
"""
|
||||
Represents a dropdown component that can be toggled open or closed. This class is used
|
||||
to create interactive dropdown elements, allowing for container and button customization.
|
||||
The dropdown provides functionality to manage its state, including opening, closing, and
|
||||
handling user interactions.
|
||||
"""
|
||||
def __init__(self, parent, content=None, button=None, _id=None):
|
||||
super().__init__(parent, _id=_id)
|
||||
self.button = Div(button) if not isinstance(button, FT) else button
|
||||
|
||||
@@ -35,6 +35,14 @@ class Commands(BaseCommands):
|
||||
|
||||
|
||||
class FileUpload(MultipleInstance):
|
||||
"""
|
||||
Represents a file upload component.
|
||||
|
||||
This class provides functionality to handle the uploading process of a file,
|
||||
extract sheet names from an Excel file, and enables users to select a specific
|
||||
sheet for further processing. It integrates commands and state management
|
||||
to ensure smooth operation within a parent application.
|
||||
"""
|
||||
|
||||
def __init__(self, parent, _id=None):
|
||||
super().__init__(parent, _id=_id)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from myfasthtml.controls.Panel import Panel
|
||||
from myfasthtml.controls.Properties import Properties
|
||||
from myfasthtml.controls.VisNetwork import VisNetwork
|
||||
from myfasthtml.core.commands import Command
|
||||
from myfasthtml.core.instances import SingleInstance, InstancesManager
|
||||
from myfasthtml.core.network_utils import from_parent_child_list
|
||||
|
||||
@@ -8,12 +10,23 @@ class InstancesDebugger(SingleInstance):
|
||||
def __init__(self, parent, _id=None):
|
||||
super().__init__(parent, _id=_id)
|
||||
self._panel = Panel(self, _id="-panel")
|
||||
self._command = Command("ShowInstance",
|
||||
"Display selected Instance",
|
||||
self.on_network_event).htmx(target=f"#{self._panel.get_id()}_r")
|
||||
|
||||
def render(self):
|
||||
nodes, edges = self._get_nodes_and_edges()
|
||||
vis_network = VisNetwork(self, nodes=nodes, edges=edges, _id="-vis")
|
||||
vis_network = VisNetwork(self, nodes=nodes, edges=edges, _id="-vis", events_handlers={"select_node": self._command})
|
||||
return self._panel.set_main(vis_network)
|
||||
|
||||
def on_network_event(self, event_data: dict):
|
||||
session, instance_id = event_data["nodes"][0].split("#")
|
||||
properties = {"Id": "_id", "Parent Id": "_parent._id"}
|
||||
return self._panel.set_right(Properties(self,
|
||||
InstancesManager.get(session, instance_id),
|
||||
properties,
|
||||
_id="-properties"))
|
||||
|
||||
def _get_nodes_and_edges(self):
|
||||
instances = self._get_instances()
|
||||
nodes, edges = from_parent_child_list(
|
||||
|
||||
@@ -7,6 +7,12 @@ from myfasthtml.core.instances import MultipleInstance
|
||||
|
||||
|
||||
class Keyboard(MultipleInstance):
|
||||
"""
|
||||
Represents a keyboard with customizable key combinations support.
|
||||
|
||||
The Keyboard class allows managing key combinations and their corresponding
|
||||
actions for a given parent object.
|
||||
"""
|
||||
def __init__(self, parent, combinations=None, _id=None):
|
||||
super().__init__(parent, _id=_id)
|
||||
self.combinations = combinations or {}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -7,6 +7,12 @@ from myfasthtml.core.instances import MultipleInstance
|
||||
|
||||
|
||||
class Mouse(MultipleInstance):
|
||||
"""
|
||||
Represents a mechanism to manage mouse event combinations and their associated commands.
|
||||
|
||||
This class is used to add, manage, and render mouse event sequences with corresponding
|
||||
commands, providing a flexible way to handle mouse interactions programmatically.
|
||||
"""
|
||||
def __init__(self, parent, _id=None, combinations=None):
|
||||
super().__init__(parent, _id=_id)
|
||||
self.combinations = combinations or {}
|
||||
|
||||
@@ -38,6 +38,15 @@ class Commands(BaseCommands):
|
||||
|
||||
|
||||
class Panel(MultipleInstance):
|
||||
"""
|
||||
Represents a user interface panel that supports customizable left, main, and right components.
|
||||
|
||||
The `Panel` class is used to create and manage a panel layout with optional left, main,
|
||||
and right sections. It provides functionality to set the components of the panel, toggle
|
||||
sides, and adjust the width of the sides dynamically. The class also handles rendering
|
||||
the panel with appropriate HTML elements and JavaScript for interactivity.
|
||||
"""
|
||||
|
||||
def __init__(self, parent, conf=None, _id=None):
|
||||
super().__init__(parent, _id=_id)
|
||||
self.conf = conf or PanelConf()
|
||||
@@ -58,35 +67,35 @@ class Panel(MultipleInstance):
|
||||
|
||||
def set_right(self, right):
|
||||
self._right = right
|
||||
return self
|
||||
return Div(self._right, id=f"{self._id}_r")
|
||||
|
||||
def set_left(self, left):
|
||||
self._left = left
|
||||
return self
|
||||
return Div(self._left, id=f"{self._id}_l")
|
||||
|
||||
def _mk_right(self):
|
||||
if not self.conf.right:
|
||||
return None
|
||||
|
||||
|
||||
resizer = Div(
|
||||
cls="mf-resizer mf-resizer-right",
|
||||
data_command_id=self.commands.update_side_width("right").id,
|
||||
data_side="right"
|
||||
)
|
||||
|
||||
return Div(resizer, self._right, cls="mf-panel-right")
|
||||
|
||||
|
||||
return Div(resizer, Div(self._right, id=f"{self._id}_r"), cls="mf-panel-right")
|
||||
|
||||
def _mk_left(self):
|
||||
if not self.conf.left:
|
||||
return None
|
||||
|
||||
|
||||
resizer = Div(
|
||||
cls="mf-resizer mf-resizer-left",
|
||||
data_command_id=self.commands.update_side_width("left").id,
|
||||
data_side="left"
|
||||
)
|
||||
|
||||
return Div(self._left, resizer, cls="mf-panel-left")
|
||||
|
||||
return Div(Div(self._left, id=f"{self._id}_l"), resizer, cls="mf-panel-left")
|
||||
|
||||
def render(self):
|
||||
return Div(
|
||||
|
||||
44
src/myfasthtml/controls/Properties.py
Normal file
44
src/myfasthtml/controls/Properties.py
Normal file
@@ -0,0 +1,44 @@
|
||||
from fasthtml.components import Div
|
||||
from myutils.Expando import Expando
|
||||
|
||||
from myfasthtml.core.instances import MultipleInstance
|
||||
|
||||
|
||||
class Properties(MultipleInstance):
|
||||
def __init__(self, parent, obj=None, properties: dict = None, _id=None):
|
||||
super().__init__(parent, _id=_id)
|
||||
self.obj = obj
|
||||
self.properties = properties or self._get_default_properties(obj)
|
||||
self.expando = self._create_expando()
|
||||
|
||||
def set_obj(self, obj, properties: list[str] = None):
|
||||
self.obj = obj
|
||||
self.properties = properties or self._get_default_properties(obj)
|
||||
|
||||
def render(self):
|
||||
return Div(
|
||||
*[Div(k, ":", v) for k, v in self.expando.as_dict().items()],
|
||||
id=self._id,
|
||||
)
|
||||
|
||||
def _create_expando(self):
|
||||
res = {}
|
||||
for attr_name, mapping in self.properties.items():
|
||||
attrs_path = mapping.split(".")
|
||||
current = self.obj
|
||||
for attr in attrs_path:
|
||||
if hasattr(current, attr):
|
||||
current = getattr(current, attr)
|
||||
else:
|
||||
res[attr_name] = None
|
||||
break
|
||||
res[attr_name] = current
|
||||
|
||||
return Expando(res)
|
||||
|
||||
@staticmethod
|
||||
def _get_default_properties(obj):
|
||||
return {k: k for k, v in dir(obj) if not k.startswith("_")} if obj else {}
|
||||
|
||||
def __ft__(self):
|
||||
return self.render()
|
||||
@@ -21,6 +21,21 @@ class Commands(BaseCommands):
|
||||
|
||||
|
||||
class Search(MultipleInstance):
|
||||
"""
|
||||
Represents a component for managing and filtering a list of items.
|
||||
It uses fuzzy matching and subsequence matching to filter items.
|
||||
|
||||
:ivar items_names: The name of the items used to filter.
|
||||
:type items_names: str
|
||||
:ivar items: The first set of items to filter.
|
||||
:type items: list
|
||||
:ivar filtered: A copy of the `items` list, representing the filtered items after a search operation.
|
||||
:type filtered: list
|
||||
:ivar get_attr: Callable function to extract string values from items for filtering.
|
||||
:type get_attr: Callable[[Any], str]
|
||||
:ivar template: Callable function to define how filtered items are rendered.
|
||||
:type template: Callable[[Any], Any]
|
||||
"""
|
||||
def __init__(self,
|
||||
parent: BaseInstance,
|
||||
_id=None,
|
||||
|
||||
@@ -25,19 +25,33 @@ class VisNetworkState(DbObject):
|
||||
},
|
||||
"physics": {"enabled": True}
|
||||
}
|
||||
self.events_handlers: dict = {} # {event_name: command_url}
|
||||
|
||||
|
||||
class VisNetwork(MultipleInstance):
|
||||
def __init__(self, parent, _id=None, nodes=None, edges=None, options=None):
|
||||
def __init__(self, parent, _id=None, nodes=None, edges=None, options=None, events_handlers=None):
|
||||
super().__init__(parent, _id=_id)
|
||||
logger.debug(f"VisNetwork created with id: {self._id}")
|
||||
|
||||
# possible events (expected in snake_case
|
||||
# - select_node → selectNode
|
||||
# - select → select
|
||||
# - click → click
|
||||
# - double_click → doubleClick
|
||||
|
||||
self._state = VisNetworkState(self)
|
||||
self._update_state(nodes, edges, options)
|
||||
|
||||
# Convert Commands to URLs
|
||||
handlers_htmx_options = {
|
||||
event_name: command.ajax_htmx_options()
|
||||
for event_name, command in events_handlers.items()
|
||||
} if events_handlers else {}
|
||||
|
||||
self._update_state(nodes, edges, options, handlers_htmx_options)
|
||||
|
||||
def _update_state(self, nodes, edges, options):
|
||||
logger.debug(f"Updating VisNetwork state with {nodes=}, {edges=}, {options=}")
|
||||
if not nodes and not edges and not options:
|
||||
def _update_state(self, nodes, edges, options, events_handlers=None):
|
||||
logger.debug(f"Updating VisNetwork state with {nodes=}, {edges=}, {options=}, {events_handlers=}")
|
||||
if not nodes and not edges and not options and not events_handlers:
|
||||
return
|
||||
|
||||
state = self._state.copy()
|
||||
@@ -47,6 +61,8 @@ class VisNetwork(MultipleInstance):
|
||||
state.edges = edges
|
||||
if options is not None:
|
||||
state.options = options
|
||||
if events_handlers is not None:
|
||||
state.events_handlers = events_handlers
|
||||
|
||||
self._state.update(state)
|
||||
|
||||
@@ -70,6 +86,34 @@ class VisNetwork(MultipleInstance):
|
||||
# Convert Python options to JS
|
||||
js_options = json.dumps(self._state.options, indent=2)
|
||||
|
||||
# Map Python event names to vis-network event names
|
||||
event_name_map = {
|
||||
"select_node": "selectNode",
|
||||
"select": "select",
|
||||
"click": "click",
|
||||
"double_click": "doubleClick"
|
||||
}
|
||||
|
||||
# Generate event handlers JavaScript
|
||||
event_handlers_js = ""
|
||||
for event_name, command_htmx_options in self._state.events_handlers.items():
|
||||
vis_event_name = event_name_map.get(event_name, event_name)
|
||||
event_handlers_js += f"""
|
||||
network.on('{vis_event_name}', function(params) {{
|
||||
const event_data = {{
|
||||
event_name: '{event_name}',
|
||||
nodes: params.nodes,
|
||||
edges: params.edges,
|
||||
pointer: params.pointer
|
||||
}};
|
||||
htmx.ajax('POST', '{command_htmx_options['url']}', {{
|
||||
values: {{event_data: JSON.stringify(event_data)}},
|
||||
target: '{command_htmx_options['target']}',
|
||||
swap: '{command_htmx_options['swap']}'
|
||||
}});
|
||||
}});
|
||||
"""
|
||||
|
||||
return (
|
||||
Div(
|
||||
id=self._id,
|
||||
@@ -92,6 +136,7 @@ class VisNetwork(MultipleInstance):
|
||||
}};
|
||||
const options = {js_options};
|
||||
const network = new vis.Network(container, data, options);
|
||||
{event_handlers_js}
|
||||
}})();
|
||||
""")
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import inspect
|
||||
import json
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
@@ -97,6 +98,14 @@ class BaseCommand:
|
||||
def url(self):
|
||||
return f"{ROUTE_ROOT}{Routes.Commands}?c_id={self.id}"
|
||||
|
||||
def ajax_htmx_options(self):
|
||||
return {
|
||||
"url": self.url,
|
||||
"target": self._htmx_extra.get("hx-target", "this"),
|
||||
"swap": self._htmx_extra.get("hx-swap", "outerHTML"),
|
||||
"values": {}
|
||||
}
|
||||
|
||||
def get_ft(self):
|
||||
return self._ft
|
||||
|
||||
@@ -141,8 +150,17 @@ class Command(BaseCommand):
|
||||
return float(value)
|
||||
elif param.annotation == list:
|
||||
return value.split(",")
|
||||
elif param.annotation == dict:
|
||||
return json.loads(value)
|
||||
return value
|
||||
|
||||
def ajax_htmx_options(self):
|
||||
res = super().ajax_htmx_options()
|
||||
if self.kwargs:
|
||||
res["values"] |= self.kwargs
|
||||
res["values"]["c_id"] = f"{self.id}" # cannot be overridden
|
||||
return res
|
||||
|
||||
def execute(self, client_response: dict = None):
|
||||
ret_from_bindings = []
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ class BaseInstance:
|
||||
return self._prefix
|
||||
|
||||
def get_full_id(self) -> str:
|
||||
return f"{InstancesManager.get_session_id(self._session)}-{self._id}"
|
||||
return f"{InstancesManager.get_session_id(self._session)}#{self._id}"
|
||||
|
||||
def get_full_parent_id(self) -> Optional[str]:
|
||||
parent = self.get_parent()
|
||||
|
||||
@@ -3,6 +3,7 @@ from collections.abc import Callable
|
||||
ROOT_COLOR = "#ff9999"
|
||||
GHOST_COLOR = "#cccccc"
|
||||
|
||||
|
||||
def from_nested_dict(trees: list[dict]) -> tuple[list, list]:
|
||||
"""
|
||||
Convert a list of nested dictionaries to vis.js nodes and edges format.
|
||||
|
||||
@@ -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,17 +151,70 @@ 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
|
||||
|
||||
|
||||
def _get_type(x):
|
||||
if hasattr(x, "tag"):
|
||||
return x.tag
|
||||
if isinstance(x, (TestObject, TestIcon)):
|
||||
return x.cls.__name__ if isinstance(x.cls, type) else str(x.cls)
|
||||
return type(x).__name__
|
||||
|
||||
|
||||
def _get_attr(x, attr):
|
||||
if hasattr(x, "attrs"):
|
||||
return x.attrs.get(attr, MISSING_ATTR)
|
||||
|
||||
if not hasattr(x, attr):
|
||||
return MISSING_ATTR
|
||||
|
||||
return getattr(x, attr, MISSING_ATTR)
|
||||
|
||||
|
||||
def _get_attributes(x):
|
||||
"""Get the attributes dict from an element."""
|
||||
if hasattr(x, "attrs"):
|
||||
return x.attrs
|
||||
return {}
|
||||
|
||||
|
||||
def _get_children(x):
|
||||
"""Get the children list from an element."""
|
||||
if hasattr(x, "children"):
|
||||
return x.children
|
||||
return []
|
||||
|
||||
|
||||
class ErrorOutput:
|
||||
def __init__(self, path, element, expected):
|
||||
self.path = path
|
||||
@@ -203,11 +285,11 @@ class ErrorOutput:
|
||||
self._add_to_output(")")
|
||||
|
||||
elif isinstance(self.expected, TestObject):
|
||||
cls = _mytype(self.element)
|
||||
attrs = {attr_name: _mygetattr(self.element, attr_name) for attr_name in self.expected.attrs}
|
||||
cls = _get_type(self.element)
|
||||
attrs = {attr_name: _get_attr(self.element, attr_name) for attr_name in self.expected.attrs}
|
||||
self._add_to_output(f"({cls} {_str_attrs(attrs)})")
|
||||
# Try to show where the differences are
|
||||
error_str = self._detect_error_2(self.element, self.expected)
|
||||
error_str = self._detect_error(self.element, self.expected)
|
||||
if error_str:
|
||||
self._add_to_output(error_str)
|
||||
|
||||
@@ -250,50 +332,35 @@ class ErrorOutput:
|
||||
return quoted_str(element)
|
||||
|
||||
def _detect_error(self, element, expected):
|
||||
if hasattr(expected, "tag") and hasattr(element, "tag"):
|
||||
tag_str = len(element.tag) * (" " if element.tag == expected.tag else "^")
|
||||
elt_attrs = {attr_name: element.attrs.get(attr_name, MISSING_ATTR) for attr_name in expected.attrs}
|
||||
attrs_in_error = [attr_name for attr_name, attr_value in elt_attrs.items() if
|
||||
not self._matches(attr_value, expected.attrs[attr_name])]
|
||||
if attrs_in_error:
|
||||
elt_attrs_error = " ".join(len(f'"{name}"="{value}"') * ("^" if name in attrs_in_error else " ")
|
||||
for name, value in elt_attrs.items())
|
||||
error_str = f" {tag_str} {elt_attrs_error}"
|
||||
return error_str
|
||||
else:
|
||||
if not self._matches(element, expected):
|
||||
return len(str(element)) * "^"
|
||||
|
||||
return None
|
||||
|
||||
def _detect_error_2(self, element, expected):
|
||||
"""
|
||||
Too lazy to refactor original _detect_error
|
||||
:param element:
|
||||
:param expected:
|
||||
:return:
|
||||
Detect errors between element and expected, returning a visual marker string.
|
||||
Unified version that handles both FT elements and TestObjects.
|
||||
"""
|
||||
# For elements with structure (FT or TestObject)
|
||||
if hasattr(expected, "tag") or isinstance(expected, TestObject):
|
||||
element_cls = _mytype(element)
|
||||
expected_cls = _mytype(expected)
|
||||
str_tag_error = (" " if self._matches(element_cls, expected_cls) else "^") * len(element_cls)
|
||||
element_type = _get_type(element)
|
||||
expected_type = _get_type(expected)
|
||||
type_error = (" " if element_type == expected_type else "^") * len(element_type)
|
||||
|
||||
element_attrs = {attr_name: _mygetattr(element, attr_name) for attr_name in expected.attrs}
|
||||
expected_attrs = {attr_name: _mygetattr(expected, attr_name) for attr_name in expected.attrs}
|
||||
element_attrs = {attr_name: _get_attr(element, attr_name) for attr_name in _get_attributes(expected)}
|
||||
expected_attrs = {attr_name: _get_attr(expected, attr_name) for attr_name in _get_attributes(expected)}
|
||||
attrs_in_error = {attr_name for attr_name, attr_value in element_attrs.items() if
|
||||
not self._matches(attr_value, expected_attrs[attr_name])}
|
||||
str_attrs_error = " ".join(len(f'"{name}"="{value}"') * ("^" if name in attrs_in_error else " ")
|
||||
for name, value in element_attrs.items())
|
||||
if str_attrs_error.strip() or str_tag_error.strip():
|
||||
return f" {str_tag_error} {str_attrs_error}"
|
||||
else:
|
||||
return None
|
||||
|
||||
attrs_error = " ".join(
|
||||
len(f'"{name}"="{value}"') * ("^" if name in attrs_in_error else " ")
|
||||
for name, value in element_attrs.items()
|
||||
)
|
||||
|
||||
if type_error.strip() or attrs_error.strip():
|
||||
return f" {type_error} {attrs_error}"
|
||||
return None
|
||||
|
||||
# For simple values
|
||||
else:
|
||||
if not self._matches(element, expected):
|
||||
return len(str(element)) * "^"
|
||||
|
||||
return None
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _matches(element, expected):
|
||||
@@ -343,39 +410,200 @@ class ErrorComparisonOutput:
|
||||
return "\n".join(output)
|
||||
|
||||
|
||||
def matches(actual, expected, path=""):
|
||||
def print_path(p):
|
||||
return f"Path : '{p}'\n" if p else ""
|
||||
class Matcher:
|
||||
"""
|
||||
Matcher class for comparing actual and expected elements.
|
||||
Provides flexible comparison with support for predicates, nested structures,
|
||||
and detailed error reporting.
|
||||
"""
|
||||
|
||||
def _type(x):
|
||||
return type(x)
|
||||
def __init__(self):
|
||||
self.path = ""
|
||||
|
||||
def _debug(elt):
|
||||
return str(elt) if elt else "None"
|
||||
def matches(self, actual, expected):
|
||||
"""
|
||||
Compare actual and expected elements.
|
||||
|
||||
Args:
|
||||
actual: The actual element to compare
|
||||
expected: The expected element or pattern
|
||||
|
||||
Returns:
|
||||
True if elements match, raises AssertionError otherwise
|
||||
"""
|
||||
if actual is not None and expected is None:
|
||||
self._assert_error("Actual is not None while expected is None", _actual=actual)
|
||||
|
||||
if isinstance(expected, DoNotCheck):
|
||||
return True
|
||||
|
||||
if actual is None and expected is not None:
|
||||
self._assert_error("Actual is None while expected is ", _expected=expected)
|
||||
|
||||
# set the path
|
||||
current_path = self._get_current_path(actual)
|
||||
original_path = self.path
|
||||
self.path = self.path + "." + current_path if self.path else current_path
|
||||
|
||||
try:
|
||||
self._match_elements(actual, expected)
|
||||
finally:
|
||||
# restore the original path for sibling comparisons
|
||||
self.path = original_path
|
||||
|
||||
return True
|
||||
|
||||
def _debug_compare(a, b):
|
||||
actual_out = ErrorOutput(path, a, b)
|
||||
expected_out = ErrorOutput(path, b, b)
|
||||
def _match_elements(self, actual, expected):
|
||||
"""Internal method that performs the actual comparison logic."""
|
||||
if isinstance(expected, TestObject) or hasattr(expected, "tag"):
|
||||
self._match_element(actual, expected)
|
||||
return
|
||||
|
||||
if isinstance(expected, Predicate):
|
||||
assert expected.validate(actual), \
|
||||
self._error_msg(f"The condition '{expected}' is not satisfied.",
|
||||
_actual=actual,
|
||||
_expected=expected)
|
||||
return
|
||||
|
||||
assert _get_type(actual) == _get_type(expected), \
|
||||
self._error_msg("The types are different.", _actual=actual, _expected=expected)
|
||||
|
||||
if isinstance(expected, (list, tuple)):
|
||||
self._match_list(actual, expected)
|
||||
elif isinstance(expected, dict):
|
||||
self._match_dict(actual, expected)
|
||||
elif isinstance(expected, NotStr):
|
||||
self._match_notstr(actual, expected)
|
||||
else:
|
||||
assert actual == expected, self._error_msg("The values are different",
|
||||
_actual=actual,
|
||||
_expected=expected)
|
||||
|
||||
def _match_element(self, actual, expected):
|
||||
"""Match a TestObject or FT element."""
|
||||
# Validate the type/tag
|
||||
assert _get_type(actual) == _get_type(expected), \
|
||||
self._error_msg("The types are different.", _actual=_get_type(actual), _expected=_get_type(expected))
|
||||
|
||||
# Special conditions (ChildrenPredicate)
|
||||
expected_children = _get_children(expected)
|
||||
for predicate in [c for c in expected_children if isinstance(c, ChildrenPredicate)]:
|
||||
assert predicate.validate(actual), \
|
||||
self._error_msg(f"The condition '{predicate}' is not satisfied.",
|
||||
_actual=actual,
|
||||
_expected=predicate.to_debug(expected))
|
||||
|
||||
# Compare the attributes
|
||||
expected_attrs = _get_attributes(expected)
|
||||
for expected_attr, expected_value in expected_attrs.items():
|
||||
actual_value = _get_attr(actual, expected_attr)
|
||||
|
||||
# Check if attribute exists
|
||||
if actual_value == MISSING_ATTR:
|
||||
self._assert_error(f"'{expected_attr}' is not found in Actual. (attributes: {self._str_attrs(actual)})",
|
||||
_actual=actual,
|
||||
_expected=expected)
|
||||
|
||||
# Handle Predicate values
|
||||
if isinstance(expected_value, Predicate):
|
||||
assert expected_value.validate(actual_value), \
|
||||
self._error_msg(f"The condition '{expected_value}' is not satisfied.",
|
||||
_actual=actual,
|
||||
_expected=expected)
|
||||
|
||||
# Handle TestObject recursive matching
|
||||
elif isinstance(expected, TestObject):
|
||||
try:
|
||||
self.matches(actual_value, expected_value)
|
||||
except AssertionError as e:
|
||||
match = re.search(r"Error : (.+?)\n", str(e))
|
||||
if match:
|
||||
self._assert_error(f"{match.group(1)} for '{expected_attr}'.",
|
||||
_actual=actual_value,
|
||||
_expected=expected_value)
|
||||
else:
|
||||
self._assert_error(f"The values are different for '{expected_attr}'.",
|
||||
_actual=actual_value,
|
||||
_expected=expected_value)
|
||||
|
||||
# Handle regular value comparison
|
||||
else:
|
||||
assert actual_value == expected_value, \
|
||||
self._error_msg(f"The values are different for '{expected_attr}'.",
|
||||
_actual=actual,
|
||||
_expected=expected)
|
||||
|
||||
# Compare the children (only if present)
|
||||
if expected_children:
|
||||
# Filter out Predicate children
|
||||
expected_children = [c for c in expected_children if not isinstance(c, Predicate)]
|
||||
actual_children = _get_children(actual)
|
||||
|
||||
if len(actual_children) < len(expected_children):
|
||||
self._assert_error("Actual is lesser than expected.", _actual=actual, _expected=expected)
|
||||
|
||||
for actual_child, expected_child in zip(actual_children, expected_children):
|
||||
assert self.matches(actual_child, expected_child)
|
||||
|
||||
def _match_list(self, actual, expected):
|
||||
"""Match list or tuple."""
|
||||
if len(actual) < len(expected):
|
||||
self._assert_error("Actual is smaller than expected: ", _actual=actual, _expected=expected)
|
||||
if len(actual) > len(expected):
|
||||
self._assert_error("Actual is bigger than expected: ", _actual=actual, _expected=expected)
|
||||
|
||||
for actual_child, expected_child in zip(actual, expected):
|
||||
assert self.matches(actual_child, expected_child)
|
||||
|
||||
def _match_dict(self, actual, expected):
|
||||
"""Match dictionary."""
|
||||
if len(actual) < len(expected):
|
||||
self._assert_error("Actual is smaller than expected: ", _actual=actual, _expected=expected)
|
||||
if len(actual) > len(expected):
|
||||
self._assert_error("Actual is bigger than expected: ", _actual=actual, _expected=expected)
|
||||
for k, v in expected.items():
|
||||
assert self.matches(actual[k], v)
|
||||
|
||||
def _match_notstr(self, actual, expected):
|
||||
"""Match NotStr type."""
|
||||
to_compare = _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)
|
||||
|
||||
def _print_path(self):
|
||||
"""Format the current path for error messages."""
|
||||
return f"Path : '{self.path}'\n" if self.path else ""
|
||||
|
||||
def _debug_compare(self, a, b):
|
||||
"""Generate a comparison debug output."""
|
||||
actual_out = ErrorOutput(self.path, a, b)
|
||||
expected_out = ErrorOutput(self.path, b, b)
|
||||
|
||||
comparison_out = ErrorComparisonOutput(actual_out, expected_out)
|
||||
return comparison_out.render()
|
||||
|
||||
def _error_msg(msg, _actual=None, _expected=None):
|
||||
def _error_msg(self, msg, _actual=None, _expected=None):
|
||||
"""Generate an error message with debug information."""
|
||||
if _actual is None and _expected is None:
|
||||
debug_info = ""
|
||||
elif _actual is None:
|
||||
debug_info = _debug(_expected)
|
||||
debug_info = self._debug(_expected)
|
||||
elif _expected is None:
|
||||
debug_info = _debug(_actual)
|
||||
debug_info = self._debug(_actual)
|
||||
else:
|
||||
debug_info = _debug_compare(_actual, _expected)
|
||||
debug_info = self._debug_compare(_actual, _expected)
|
||||
|
||||
return f"{print_path(path)}Error : {msg}\n{debug_info}"
|
||||
return f"{self._print_path()}Error : {msg}\n{debug_info}"
|
||||
|
||||
def _assert_error(msg, _actual=None, _expected=None):
|
||||
assert False, _error_msg(msg, _actual=_actual, _expected=_expected)
|
||||
def _assert_error(self, msg, _actual=None, _expected=None):
|
||||
"""Raise an assertion error with formatted message."""
|
||||
assert False, self._error_msg(msg, _actual=_actual, _expected=_expected)
|
||||
|
||||
@staticmethod
|
||||
def _get_current_path(elt):
|
||||
"""Get the path representation of an element."""
|
||||
if hasattr(elt, "tag"):
|
||||
res = f"{elt.tag}"
|
||||
if "id" in elt.attrs:
|
||||
@@ -388,192 +616,127 @@ def matches(actual, expected, path=""):
|
||||
else:
|
||||
return elt.__class__.__name__
|
||||
|
||||
if actual is not None and expected is None:
|
||||
_assert_error("Actual is not None while expected is None", _actual=actual)
|
||||
@staticmethod
|
||||
def _str_attrs(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())
|
||||
|
||||
if isinstance(expected, DoNotCheck):
|
||||
return True
|
||||
|
||||
if actual is None and expected is not None:
|
||||
_assert_error("Actual is None while expected is ", _expected=expected)
|
||||
|
||||
# set the path
|
||||
path += "." + _get_current_path(actual) if path else _get_current_path(actual)
|
||||
|
||||
if isinstance(expected, TestObject):
|
||||
assert _mytype(actual) == _mytype(expected), _error_msg("The types are different: ",
|
||||
_actual=actual,
|
||||
_expected=expected)
|
||||
|
||||
for attr, value in expected.attrs.items():
|
||||
assert hasattr(actual, attr), _error_msg(f"'{attr}' is not found in Actual.",
|
||||
_actual=actual,
|
||||
_expected=expected)
|
||||
try:
|
||||
matches(getattr(actual, attr), value)
|
||||
except AssertionError as e:
|
||||
match = re.search(r"Error : (.+?)\n", str(e))
|
||||
if match:
|
||||
assert False, _error_msg(f"{match.group(1)} for '{attr}':",
|
||||
_actual=getattr(actual, attr),
|
||||
_expected=value)
|
||||
assert False, _error_msg(f"The values are different for '{attr}': ",
|
||||
_actual=getattr(actual, attr),
|
||||
_expected=value)
|
||||
return True
|
||||
|
||||
if isinstance(expected, Predicate):
|
||||
assert expected.validate(actual), \
|
||||
_error_msg(f"The condition '{expected}' is not satisfied.",
|
||||
_actual=actual,
|
||||
_expected=expected)
|
||||
|
||||
assert _type(actual) == _type(expected) or (hasattr(actual, "tag") and hasattr(expected, "tag")), \
|
||||
_error_msg("The types are different: ", _actual=actual, _expected=expected)
|
||||
|
||||
if isinstance(expected, (list, tuple)):
|
||||
if len(actual) < len(expected):
|
||||
_assert_error("Actual is smaller than expected: ", _actual=actual, _expected=expected)
|
||||
if len(actual) > len(expected):
|
||||
_assert_error("Actual is bigger than expected: ", _actual=actual, _expected=expected)
|
||||
|
||||
for actual_child, expected_child in zip(actual, expected):
|
||||
assert matches(actual_child, expected_child, path=path)
|
||||
|
||||
elif isinstance(expected, dict):
|
||||
if len(actual) < len(expected):
|
||||
_assert_error("Actual is smaller than expected: ", _actual=actual, _expected=expected)
|
||||
if len(actual) > len(expected):
|
||||
_assert_error("Actual is bigger than expected: ", _actual=actual, _expected=expected)
|
||||
for k, v in expected.items():
|
||||
assert matches(actual[k], v, path=f"{path}[{k}={v}]")
|
||||
|
||||
elif isinstance(expected, NotStr):
|
||||
to_compare = actual.s.lstrip('\n').lstrip()
|
||||
assert to_compare.startswith(expected.s), _error_msg("Notstr values are different: ",
|
||||
_actual=to_compare,
|
||||
_expected=expected.s)
|
||||
|
||||
elif hasattr(expected, "tag"):
|
||||
# validate the tags names
|
||||
assert actual.tag == expected.tag, _error_msg("The elements are different.",
|
||||
_actual=actual.tag,
|
||||
_expected=expected.tag)
|
||||
|
||||
# special conditions
|
||||
for predicate in [c for c in expected.children if isinstance(c, ChildrenPredicate)]:
|
||||
assert predicate.validate(actual), \
|
||||
_error_msg(f"The condition '{predicate}' is not satisfied.",
|
||||
_actual=actual,
|
||||
_expected=predicate.to_debug(expected))
|
||||
|
||||
# compare the attributes
|
||||
for expected_attr, expected_value in expected.attrs.items():
|
||||
assert expected_attr in actual.attrs, _error_msg(f"'{expected_attr}' is not found in Actual.",
|
||||
_actual=actual,
|
||||
_expected=expected)
|
||||
|
||||
if isinstance(expected_value, Predicate):
|
||||
assert expected_value.validate(actual.attrs[expected_attr]), \
|
||||
_error_msg(f"The condition '{expected_value}' is not satisfied.",
|
||||
_actual=actual,
|
||||
_expected=expected)
|
||||
|
||||
else:
|
||||
assert actual.attrs[expected_attr] == expected.attrs[expected_attr], \
|
||||
_error_msg(f"The values are different for '{expected_attr}': ",
|
||||
_actual=actual,
|
||||
_expected=expected)
|
||||
|
||||
# compare the children
|
||||
expected_children = [c for c in expected.children if not isinstance(c, Predicate)]
|
||||
if len(actual.children) < len(expected_children):
|
||||
_assert_error("Actual is lesser than expected: ", _actual=actual, _expected=expected)
|
||||
|
||||
for actual_child, expected_child in zip(actual.children, expected_children):
|
||||
assert matches(actual_child, expected_child, path=path)
|
||||
|
||||
|
||||
else:
|
||||
assert actual == expected, _error_msg("The values are different",
|
||||
_actual=actual,
|
||||
_expected=expected)
|
||||
|
||||
return True
|
||||
@staticmethod
|
||||
def _debug(elt):
|
||||
"""Format an element for debug output."""
|
||||
return str(elt) if elt else "None"
|
||||
|
||||
|
||||
def matches(actual, expected, path=""):
|
||||
"""
|
||||
Compare actual and expected elements.
|
||||
|
||||
This is a convenience wrapper around the Matcher class.
|
||||
|
||||
Args:
|
||||
actual: The actual element to compare
|
||||
expected: The expected element or pattern
|
||||
path: Optional initial path for error reporting
|
||||
|
||||
Returns:
|
||||
True if elements match, raises AssertionError otherwise
|
||||
"""
|
||||
matcher = Matcher()
|
||||
matcher.path = path
|
||||
return matcher.matches(actual, expected)
|
||||
|
||||
|
||||
def find(ft, expected):
|
||||
res = []
|
||||
"""
|
||||
Find all occurrences of an expected element within a FastHTML tree.
|
||||
|
||||
Args:
|
||||
ft: A FastHTML element or list of elements to search in
|
||||
expected: The element pattern to find
|
||||
|
||||
Returns:
|
||||
List of matching elements
|
||||
|
||||
Raises:
|
||||
AssertionError: If no matching elements are found
|
||||
"""
|
||||
|
||||
def _type(x):
|
||||
return type(x)
|
||||
|
||||
def _same(_ft, _expected):
|
||||
if _ft == _expected:
|
||||
def _elements_match(actual, expected):
|
||||
"""Check if two elements are the same based on tag, attributes, and children."""
|
||||
if isinstance(expected, DoNotCheck):
|
||||
return True
|
||||
|
||||
if _ft.tag != _expected.tag:
|
||||
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):
|
||||
return False
|
||||
|
||||
for attr in _expected.attrs:
|
||||
if attr not in _ft.attrs or _ft.attrs[attr] != _expected.attrs[attr]:
|
||||
# Compare attributes
|
||||
expected_attrs = _get_attributes(expected)
|
||||
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
|
||||
|
||||
for expected_child in _expected.children:
|
||||
for ft_child in _ft.children:
|
||||
if _same(ft_child, expected_child):
|
||||
break
|
||||
else:
|
||||
# Compare children recursively
|
||||
expected_children = _get_children(expected)
|
||||
actual_children = _get_children(actual)
|
||||
|
||||
for expected_child in expected_children:
|
||||
# Check if this expected child exists somewhere in actual children
|
||||
if not any(_elements_match(actual_child, expected_child) for actual_child in actual_children):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def _find(current, current_expected):
|
||||
def _search_tree(current, pattern):
|
||||
"""Recursively search for pattern in the tree rooted at current."""
|
||||
# Check if current element matches
|
||||
matches = []
|
||||
if _elements_match(current, pattern):
|
||||
matches.append(current)
|
||||
|
||||
if _type(current) != _type(current_expected):
|
||||
return []
|
||||
# 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))
|
||||
|
||||
if not hasattr(current, "tag"):
|
||||
return [current] if current == current_expected else []
|
||||
|
||||
_found = []
|
||||
if _same(current, current_expected):
|
||||
_found.append(current)
|
||||
|
||||
# look at the children
|
||||
for child in current.children:
|
||||
_found.extend(_find(child, current_expected))
|
||||
|
||||
return _found
|
||||
return matches
|
||||
|
||||
ft_as_list = [ft] if not isinstance(ft, (list, tuple, set)) else ft
|
||||
# Normalize input to list
|
||||
elements_to_search = ft if isinstance(ft, (list, tuple, set)) else [ft]
|
||||
|
||||
for current_ft in ft_as_list:
|
||||
found = _find(current_ft, expected)
|
||||
res.extend(found)
|
||||
# Search in all provided elements
|
||||
all_matches = []
|
||||
for element in elements_to_search:
|
||||
all_matches.extend(_search_tree(element, expected))
|
||||
|
||||
if len(res) == 0:
|
||||
# Raise error if nothing found
|
||||
if not all_matches:
|
||||
raise AssertionError(f"No element found for '{expected}'")
|
||||
|
||||
return res
|
||||
return all_matches
|
||||
|
||||
|
||||
def _mytype(x):
|
||||
if hasattr(x, "tag"):
|
||||
return x.tag
|
||||
if isinstance(x, TestObject):
|
||||
return x.cls.__name__ if isinstance(x.cls, type) else str(x.cls)
|
||||
return type(x).__name__
|
||||
|
||||
|
||||
def _mygetattr(x, attr):
|
||||
if hasattr(x, "attrs"):
|
||||
return x.attrs.get(attr, MISSING_ATTR)
|
||||
|
||||
if not hasattr(x, attr):
|
||||
return MISSING_ATTR
|
||||
|
||||
return getattr(x, attr, MISSING_ATTR)
|
||||
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):
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import pytest
|
||||
|
||||
from myfasthtml.core.instances import SingleInstance
|
||||
from myfasthtml.core.instances import SingleInstance, InstancesManager
|
||||
|
||||
|
||||
class RootInstanceForTests(SingleInstance):
|
||||
@@ -25,4 +25,5 @@ def session():
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def root_instance(session):
|
||||
InstancesManager.reset()
|
||||
return RootInstanceForTests(session=session)
|
||||
|
||||
458
tests/controls/test_layout.py
Normal file
458
tests/controls/test_layout.py
Normal file
@@ -0,0 +1,458 @@
|
||||
"""Unit tests for Layout component."""
|
||||
import shutil
|
||||
|
||||
import pytest
|
||||
from fasthtml.components import *
|
||||
|
||||
from myfasthtml.controls.Layout import Layout
|
||||
from myfasthtml.test.matcher import matches, find, Contains, find_one, TestIcon, TestScript
|
||||
from .conftest import root_instance
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def cleanup_db():
|
||||
shutil.rmtree(".myFastHtmlDb", ignore_errors=True)
|
||||
|
||||
|
||||
class TestLayoutBehaviour:
|
||||
"""Tests for Layout behavior and logic."""
|
||||
|
||||
def test_i_can_create_layout(self, root_instance):
|
||||
"""Test basic layout creation with app_name."""
|
||||
layout = Layout(root_instance, app_name="Test App")
|
||||
|
||||
assert layout is not None
|
||||
assert layout.app_name == "Test App"
|
||||
assert layout._state is not None
|
||||
|
||||
def test_i_can_set_main_content(self, root_instance):
|
||||
"""Test setting main content area."""
|
||||
layout = Layout(root_instance, app_name="Test App")
|
||||
content = Div("Main content")
|
||||
|
||||
result = layout.set_main(content)
|
||||
|
||||
assert layout._main_content == content
|
||||
assert result == layout # Should return self for chaining
|
||||
|
||||
def test_i_can_add_content_to_left_drawer(self, root_instance):
|
||||
"""Test adding content to left drawer."""
|
||||
layout = Layout(root_instance, app_name="Test App")
|
||||
content = Div("Left drawer content", id="drawer_item")
|
||||
|
||||
layout.left_drawer.add(content)
|
||||
|
||||
drawer_content = layout.left_drawer.get_content()
|
||||
assert None in drawer_content
|
||||
assert content in drawer_content[None]
|
||||
|
||||
def test_i_can_add_content_to_right_drawer(self, root_instance):
|
||||
"""Test adding content to right drawer."""
|
||||
layout = Layout(root_instance, app_name="Test App")
|
||||
content = Div("Right drawer content", id="drawer_item")
|
||||
|
||||
layout.right_drawer.add(content)
|
||||
|
||||
drawer_content = layout.right_drawer.get_content()
|
||||
assert None in drawer_content
|
||||
assert content in drawer_content[None]
|
||||
|
||||
def test_i_can_add_content_to_header_left(self, root_instance):
|
||||
"""Test adding content to left side of header."""
|
||||
layout = Layout(root_instance, app_name="Test App")
|
||||
content = Div("Header left content", id="header_item")
|
||||
|
||||
layout.header_left.add(content)
|
||||
|
||||
header_content = layout.header_left.get_content()
|
||||
assert None in header_content
|
||||
assert content in header_content[None]
|
||||
|
||||
def test_i_can_add_content_to_header_right(self, root_instance):
|
||||
"""Test adding content to right side of header."""
|
||||
layout = Layout(root_instance, app_name="Test App")
|
||||
content = Div("Header right content", id="header_item")
|
||||
|
||||
layout.header_right.add(content)
|
||||
|
||||
header_content = layout.header_right.get_content()
|
||||
assert None in header_content
|
||||
assert content in header_content[None]
|
||||
|
||||
def test_i_can_add_grouped_content(self, root_instance):
|
||||
"""Test adding content with custom groups."""
|
||||
layout = Layout(root_instance, app_name="Test App")
|
||||
group_name = "navigation"
|
||||
content1 = Div("Nav item 1", id="nav1")
|
||||
content2 = Div("Nav item 2", id="nav2")
|
||||
|
||||
layout.left_drawer.add(content1, group=group_name)
|
||||
layout.left_drawer.add(content2, group=group_name)
|
||||
|
||||
drawer_content = layout.left_drawer.get_content()
|
||||
assert group_name in drawer_content
|
||||
assert content1 in drawer_content[group_name]
|
||||
assert content2 in drawer_content[group_name]
|
||||
|
||||
def test_i_cannot_add_duplicate_content(self, root_instance):
|
||||
"""Test that content with same ID is not added twice."""
|
||||
layout = Layout(root_instance, app_name="Test App")
|
||||
content = Div("Content", id="unique_id")
|
||||
|
||||
layout.left_drawer.add(content)
|
||||
layout.left_drawer.add(content) # Try to add again
|
||||
|
||||
drawer_content = layout.left_drawer.get_content()
|
||||
# Content should appear only once
|
||||
assert drawer_content[None].count(content) == 1
|
||||
|
||||
def test_i_can_toggle_left_drawer(self, root_instance):
|
||||
"""Test toggling left drawer open/closed."""
|
||||
layout = Layout(root_instance, app_name="Test App")
|
||||
|
||||
# Initially open
|
||||
assert layout._state.left_drawer_open is True
|
||||
|
||||
# Toggle to close
|
||||
layout.toggle_drawer("left")
|
||||
assert layout._state.left_drawer_open is False
|
||||
|
||||
# Toggle to open
|
||||
layout.toggle_drawer("left")
|
||||
assert layout._state.left_drawer_open is True
|
||||
|
||||
def test_i_can_toggle_right_drawer(self, root_instance):
|
||||
"""Test toggling right drawer open/closed."""
|
||||
layout = Layout(root_instance, app_name="Test App")
|
||||
|
||||
# Initially open
|
||||
assert layout._state.right_drawer_open is True
|
||||
|
||||
# Toggle to close
|
||||
layout.toggle_drawer("right")
|
||||
assert layout._state.right_drawer_open is False
|
||||
|
||||
# Toggle to open
|
||||
layout.toggle_drawer("right")
|
||||
assert layout._state.right_drawer_open is True
|
||||
|
||||
def test_i_can_update_left_drawer_width(self, root_instance):
|
||||
"""Test updating left drawer width."""
|
||||
layout = Layout(root_instance, app_name="Test App")
|
||||
new_width = 300
|
||||
|
||||
layout.update_drawer_width("left", new_width)
|
||||
|
||||
assert layout._state.left_drawer_width == new_width
|
||||
|
||||
def test_i_can_update_right_drawer_width(self, root_instance):
|
||||
"""Test updating right drawer width."""
|
||||
layout = Layout(root_instance, app_name="Test App")
|
||||
new_width = 400
|
||||
|
||||
layout.update_drawer_width("right", new_width)
|
||||
|
||||
assert layout._state.right_drawer_width == new_width
|
||||
|
||||
def test_i_cannot_set_drawer_width_below_minimum(self, root_instance):
|
||||
"""Test that drawer width is constrained to minimum 150px."""
|
||||
layout = Layout(root_instance, app_name="Test App")
|
||||
|
||||
layout.update_drawer_width("left", 100) # Try to set below minimum
|
||||
|
||||
assert layout._state.left_drawer_width == 150 # Should be clamped to min
|
||||
|
||||
def test_i_cannot_set_drawer_width_above_maximum(self, root_instance):
|
||||
"""Test that drawer width is constrained to maximum 600px."""
|
||||
layout = Layout(root_instance, app_name="Test App")
|
||||
|
||||
layout.update_drawer_width("right", 800) # Try to set above maximum
|
||||
|
||||
assert layout._state.right_drawer_width == 600 # Should be clamped to max
|
||||
|
||||
def test_i_cannot_toggle_invalid_drawer_side(self, root_instance):
|
||||
"""Test that toggling invalid drawer side raises ValueError."""
|
||||
layout = Layout(root_instance, app_name="Test App")
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid drawer side"):
|
||||
layout.toggle_drawer("invalid")
|
||||
|
||||
def test_i_cannot_update_invalid_drawer_width(self, root_instance):
|
||||
"""Test that updating invalid drawer side raises ValueError."""
|
||||
layout = Layout(root_instance, app_name="Test App")
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid drawer side"):
|
||||
layout.update_drawer_width("invalid", 250)
|
||||
|
||||
def test_layout_state_has_correct_defaults(self, root_instance):
|
||||
"""Test that LayoutState initializes with correct default values."""
|
||||
layout = Layout(root_instance, app_name="Test App")
|
||||
state = layout._state
|
||||
|
||||
assert state.left_drawer_open is True
|
||||
assert state.right_drawer_open is True
|
||||
assert state.left_drawer_width == 250
|
||||
assert state.right_drawer_width == 250
|
||||
|
||||
def test_layout_is_single_instance(self, root_instance):
|
||||
"""Test that Layout behaves as SingleInstance (same ID returns same instance)."""
|
||||
layout1 = 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
|
||||
assert layout1 is layout2
|
||||
|
||||
def test_commands_are_created(self, root_instance):
|
||||
"""Test that Layout creates necessary commands."""
|
||||
layout = Layout(root_instance, app_name="Test App")
|
||||
|
||||
# Test toggle commands
|
||||
left_toggle_cmd = layout.commands.toggle_drawer("left")
|
||||
assert left_toggle_cmd is not None
|
||||
assert left_toggle_cmd.id is not None
|
||||
|
||||
right_toggle_cmd = layout.commands.toggle_drawer("right")
|
||||
assert right_toggle_cmd is not None
|
||||
assert right_toggle_cmd.id is not None
|
||||
|
||||
# Test width update commands
|
||||
left_width_cmd = layout.commands.update_drawer_width("left")
|
||||
assert left_width_cmd is not None
|
||||
assert left_width_cmd.id is not None
|
||||
|
||||
right_width_cmd = layout.commands.update_drawer_width("right")
|
||||
assert right_width_cmd is not None
|
||||
assert right_width_cmd.id is not None
|
||||
|
||||
|
||||
class TestLayoutRender:
|
||||
"""Tests for Layout HTML rendering."""
|
||||
|
||||
@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:
|
||||
- 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(
|
||||
Header(),
|
||||
Div(), # left drawer
|
||||
Main(),
|
||||
Div(), # right drawer
|
||||
Footer(),
|
||||
Script(),
|
||||
_id=layout._id,
|
||||
cls="mf-layout"
|
||||
)
|
||||
|
||||
assert matches(layout.render(), expected)
|
||||
|
||||
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:
|
||||
- Only the first div is required to test the presence of the icon
|
||||
- Use TestIcon to test the existence of an icon
|
||||
"""
|
||||
header = find_one(layout.render(), Header(cls="mf-layout-header"))
|
||||
|
||||
expected = Header(
|
||||
Div(
|
||||
TestIcon("panel_right_expand20_regular"),
|
||||
cls="flex gap-1"
|
||||
),
|
||||
cls="mf-layout-header"
|
||||
)
|
||||
|
||||
assert matches(header, expected)
|
||||
|
||||
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.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, layout):
|
||||
"""Test that left drawer renders with correct classes when open.
|
||||
|
||||
Why these elements matter:
|
||||
- _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._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)
|
||||
|
||||
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:
|
||||
- _id: Required for targeting drawer in HTMX updates
|
||||
- cls Contains "collapsed": Class triggers CSS hiding animation
|
||||
- style Contains "width: 0px": Zero width is crucial for collapse animation
|
||||
"""
|
||||
layout._state.left_drawer_open = False
|
||||
drawer = find_one(layout.render(), Div(id=f"{layout._id}_ld"))
|
||||
|
||||
expected = Div(
|
||||
_id=f"{layout._id}_ld",
|
||||
cls=Contains("collapsed"),
|
||||
style=Contains("width: 0px")
|
||||
)
|
||||
|
||||
assert matches(drawer, expected)
|
||||
|
||||
def test_right_drawer_is_rendered_when_open(self, layout):
|
||||
"""Test that right 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-right-drawer": Right-specific drawer positioning
|
||||
- style Contains width: Drawer width must be applied for sizing
|
||||
"""
|
||||
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", "mf-layout-right-drawer"),
|
||||
style=Contains("width: 250px")
|
||||
)
|
||||
|
||||
assert matches(drawer, expected)
|
||||
|
||||
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:
|
||||
- _id: Required for targeting drawer in HTMX updates
|
||||
- cls Contains "collapsed": Class triggers CSS hiding animation
|
||||
- style Contains "width: 0px": Zero width is crucial for collapse animation
|
||||
"""
|
||||
layout._state.right_drawer_open = False
|
||||
drawer = find_one(layout.render(), Div(id=f"{layout._id}_rd"))
|
||||
|
||||
expected = Div(
|
||||
_id=f"{layout._id}_rd",
|
||||
cls=Contains("collapsed"),
|
||||
style=Contains("width: 0px")
|
||||
)
|
||||
|
||||
assert matches(drawer, expected)
|
||||
|
||||
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._state.left_drawer_width = 300
|
||||
drawer = find_one(layout.render(), Div(id=f"{layout._id}_ld"))
|
||||
|
||||
expected = Div(
|
||||
style=Contains("width: 300px")
|
||||
)
|
||||
|
||||
assert matches(drawer, expected)
|
||||
|
||||
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
|
||||
"""
|
||||
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, 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
|
||||
"""
|
||||
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, 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.left_drawer.add(Div("Item 1"), group="group1")
|
||||
layout.left_drawer.add(Div("Item 2"), group="group2")
|
||||
|
||||
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
|
||||
|
||||
content = content_wrappers[0]
|
||||
|
||||
dividers = find(content, Div(cls="divider"))
|
||||
assert len(dividers) >= 1, "Groups should be separated by dividers"
|
||||
|
||||
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
|
||||
"""
|
||||
script = find_one(layout.render(), Script())
|
||||
expected = TestScript(f"initResizer('{layout._id}');")
|
||||
|
||||
assert matches(script, expected)
|
||||
@@ -6,7 +6,7 @@ from fasthtml.components import *
|
||||
|
||||
from myfasthtml.controls.Keyboard import Keyboard
|
||||
from myfasthtml.controls.TreeView import TreeView, TreeNode
|
||||
from myfasthtml.test.matcher import matches, TestObject, TestCommand
|
||||
from myfasthtml.test.matcher import matches, TestObject, TestCommand, TestIcon, find_one, find, Contains, DoesNotContain
|
||||
from .conftest import root_instance
|
||||
|
||||
|
||||
@@ -381,18 +381,247 @@ class TestTreeviewBehaviour:
|
||||
class TestTreeViewRender:
|
||||
"""Tests for TreeView HTML rendering."""
|
||||
|
||||
def test_i_can_render_empty_treeview(self, root_instance):
|
||||
"""Test that TreeView generates correct HTML structure."""
|
||||
def test_empty_treeview_is_rendered(self, root_instance):
|
||||
"""Test that empty TreeView generates correct HTML structure.
|
||||
|
||||
Why these elements matter:
|
||||
- TestObject Keyboard: Essential for keyboard shortcuts (Escape to cancel rename)
|
||||
- _id: Required for HTMX targeting and component identification
|
||||
- cls "mf-treeview": Root CSS class for TreeView styling
|
||||
"""
|
||||
# Step 1: Create empty TreeView
|
||||
tree_view = TreeView(root_instance)
|
||||
|
||||
# Step 2: Define expected structure
|
||||
expected = Div(
|
||||
TestObject(Keyboard, combinations={"esc": TestCommand("CancelRename")}),
|
||||
_id=tree_view.get_id(),
|
||||
cls="mf-treeview"
|
||||
)
|
||||
|
||||
|
||||
# Step 3: Compare
|
||||
assert matches(tree_view.__ft__(), expected)
|
||||
|
||||
def test_node_action_buttons_are_rendered(self):
|
||||
"""Test that action buttons are present in rendered HTML."""
|
||||
# Signature only - implementation later
|
||||
pass
|
||||
@pytest.fixture
|
||||
def tree_view(self, root_instance):
|
||||
return TreeView(root_instance)
|
||||
|
||||
def test_node_with_children_collapsed_is_rendered(self, tree_view):
|
||||
"""Test that a collapsed node with children renders correctly.
|
||||
|
||||
Why these elements matter:
|
||||
- TestIcon chevron_right: Indicates visually that the node is collapsed
|
||||
- Span with label: Displays the node's text content
|
||||
- Action buttons (add_child, edit, delete): Enable user interactions
|
||||
- cls "mf-treenode": Required CSS class for node styling
|
||||
- data_node_id: Essential for identifying the node in DOM operations
|
||||
- No children in container: Verifies children are hidden when collapsed
|
||||
"""
|
||||
parent = TreeNode(label="Parent", type="folder")
|
||||
child = TreeNode(label="Child", type="file")
|
||||
|
||||
tree_view.add_node(parent)
|
||||
tree_view.add_node(child, parent_id=parent.id)
|
||||
|
||||
# Step 1: Extract the node element to test
|
||||
rendered = tree_view.render()
|
||||
node_container = find_one(rendered, Div(data_node_id=parent.id))
|
||||
|
||||
# Step 2: Define expected structure
|
||||
expected = Div(
|
||||
TestIcon("chevron_right20_regular"), # Collapsed toggle icon
|
||||
Span("Parent"), # Label
|
||||
Div( # Action buttons
|
||||
TestIcon("add_circle20_regular"),
|
||||
TestIcon("edit20_regular"),
|
||||
TestIcon("delete20_regular"),
|
||||
cls=Contains("mf-treenode-actions")
|
||||
),
|
||||
cls=Contains("mf-treenode"),
|
||||
data_node_id=parent.id
|
||||
)
|
||||
|
||||
# Step 3: Compare
|
||||
assert matches(node_container, expected)
|
||||
|
||||
# Verify no children are rendered (collapsed)
|
||||
child_containers = find(node_container, Div(data_node_id=child.id))
|
||||
assert len(child_containers) == 0, "Children should not be rendered when node is collapsed"
|
||||
|
||||
def test_node_with_children_expanded_is_rendered(self, tree_view):
|
||||
"""Test that an expanded node with children renders correctly.
|
||||
|
||||
Why these elements matter:
|
||||
- TestIcon chevron_down: Indicates visually that the node is expanded
|
||||
- Children rendered: Verifies that child nodes are visible when parent is expanded
|
||||
- Child has its own node structure: Ensures recursive rendering works correctly
|
||||
"""
|
||||
parent = TreeNode(label="Parent", type="folder")
|
||||
child = TreeNode(label="Child", type="file")
|
||||
|
||||
tree_view.add_node(parent)
|
||||
tree_view.add_node(child, parent_id=parent.id)
|
||||
tree_view._toggle_node(parent.id) # Expand the parent
|
||||
|
||||
# Step 1: Extract the parent node element to test
|
||||
rendered = tree_view.render()
|
||||
parent_node = find_one(rendered, Div(data_node_id=parent.id))
|
||||
|
||||
# Step 2: Define expected structure for toggle icon
|
||||
expected = Div(
|
||||
TestIcon("chevron_down20_regular"), # Expanded toggle icon
|
||||
cls=Contains("mf-treenode")
|
||||
)
|
||||
|
||||
# Step 3: Compare
|
||||
assert matches(parent_node, expected)
|
||||
|
||||
# Verify children ARE rendered (expanded)
|
||||
child_containers = find(rendered, Div(data_node_id=child.id))
|
||||
assert len(child_containers) == 1, "Child should be rendered when parent is expanded"
|
||||
|
||||
# Verify child has proper node structure
|
||||
child_node = child_containers[0]
|
||||
child_expected = Div(
|
||||
Span("Child"),
|
||||
cls=Contains("mf-treenode"),
|
||||
data_node_id=child.id
|
||||
)
|
||||
assert matches(child_node, child_expected)
|
||||
|
||||
def test_leaf_node_is_rendered(self, tree_view):
|
||||
"""Test that a leaf node (no children) renders without toggle icon.
|
||||
|
||||
Why these elements matter:
|
||||
- No toggle icon (or empty space): Leaf nodes don't need expand/collapse functionality
|
||||
- Span with label: Displays the node's text content
|
||||
- Action buttons present: Even leaf nodes can be edited, deleted, or receive children
|
||||
"""
|
||||
leaf = TreeNode(label="Leaf Node", type="file")
|
||||
tree_view.add_node(leaf)
|
||||
|
||||
# Step 1: Extract the leaf node element to test
|
||||
rendered = tree_view.render()
|
||||
leaf_node = find_one(rendered, Div(data_node_id=leaf.id))
|
||||
|
||||
# Step 2: Define expected structure
|
||||
expected = Div(
|
||||
Span("Leaf Node"), # Label
|
||||
Div( # Action buttons still present
|
||||
TestIcon("add_circle20_regular"),
|
||||
TestIcon("edit20_regular"),
|
||||
TestIcon("delete20_regular"),
|
||||
cls=Contains("mf-treenode-actions")
|
||||
),
|
||||
cls=Contains("mf-treenode"),
|
||||
data_node_id=leaf.id
|
||||
)
|
||||
|
||||
# Step 3: Compare
|
||||
assert matches(leaf_node, expected)
|
||||
|
||||
def test_selected_node_has_selected_class(self, tree_view):
|
||||
"""Test that a selected node has the 'selected' CSS class.
|
||||
|
||||
Why these elements matter:
|
||||
- cls Contains "selected": Enables visual highlighting of the selected node
|
||||
- data_node_id: Required for identifying which node is selected
|
||||
"""
|
||||
node = TreeNode(label="Selected Node", type="file")
|
||||
tree_view.add_node(node)
|
||||
tree_view._select_node(node.id)
|
||||
|
||||
# Step 1: Extract the selected node element to test
|
||||
rendered = tree_view.render()
|
||||
selected_node = find_one(rendered, Div(data_node_id=node.id))
|
||||
|
||||
# Step 2: Define expected structure
|
||||
expected = Div(
|
||||
cls=Contains("mf-treenode", "selected"),
|
||||
data_node_id=node.id
|
||||
)
|
||||
|
||||
# Step 3: Compare
|
||||
assert matches(selected_node, expected)
|
||||
|
||||
def test_node_in_editing_mode_shows_input(self, tree_view):
|
||||
"""Test that a node in editing mode renders an Input instead of Span.
|
||||
|
||||
Why these elements matter:
|
||||
- Input element: Enables user to modify the node label inline
|
||||
- cls "mf-treenode-input": Required CSS class for input field styling
|
||||
- name "node_label": Essential for form data submission
|
||||
- value with current label: Pre-fills the input with existing text
|
||||
- cls does NOT contain "selected": Avoids double highlighting during editing
|
||||
"""
|
||||
node = TreeNode(label="Edit Me", type="file")
|
||||
tree_view.add_node(node)
|
||||
tree_view._start_rename(node.id)
|
||||
|
||||
# Step 1: Extract the editing node element to test
|
||||
rendered = tree_view.render()
|
||||
editing_node = find_one(rendered, Div(data_node_id=node.id))
|
||||
|
||||
# Step 2: Define expected structure
|
||||
expected = Div(
|
||||
Input(
|
||||
name="node_label",
|
||||
value="Edit Me",
|
||||
cls=Contains("mf-treenode-input")
|
||||
),
|
||||
cls=Contains("mf-treenode"),
|
||||
data_node_id=node.id
|
||||
)
|
||||
|
||||
# Step 3: Compare
|
||||
assert matches(editing_node, expected)
|
||||
|
||||
# Verify "selected" class is NOT present
|
||||
no_selected = Div(
|
||||
cls=DoesNotContain("selected")
|
||||
)
|
||||
assert matches(editing_node, no_selected)
|
||||
|
||||
def test_node_indentation_increases_with_level(self, tree_view):
|
||||
"""Test that node indentation increases correctly with hierarchy level.
|
||||
|
||||
Why these elements matter:
|
||||
- style Contains "padding-left: 0px": Root node has no indentation
|
||||
- style Contains "padding-left: 20px": Child is indented by 20px
|
||||
- style Contains "padding-left: 40px": Grandchild is indented by 40px
|
||||
- Progressive padding: Creates the visual hierarchy of the tree structure
|
||||
"""
|
||||
root = TreeNode(label="Root", type="folder")
|
||||
child = TreeNode(label="Child", type="folder")
|
||||
grandchild = TreeNode(label="Grandchild", type="file")
|
||||
|
||||
tree_view.add_node(root)
|
||||
tree_view.add_node(child, parent_id=root.id)
|
||||
tree_view.add_node(grandchild, parent_id=child.id)
|
||||
|
||||
# Expand all to make hierarchy visible
|
||||
tree_view._toggle_node(root.id)
|
||||
tree_view._toggle_node(child.id)
|
||||
|
||||
rendered = tree_view.render()
|
||||
|
||||
# Step 1 & 2 & 3: Test root node (level 0)
|
||||
root_node = find_one(rendered, Div(data_node_id=root.id))
|
||||
root_expected = Div(
|
||||
style=Contains("padding-left: 0px")
|
||||
)
|
||||
assert matches(root_node, root_expected)
|
||||
|
||||
# Step 1 & 2 & 3: Test child node (level 1)
|
||||
child_node = find_one(rendered, Div(data_node_id=child.id))
|
||||
child_expected = Div(
|
||||
style=Contains("padding-left: 20px")
|
||||
)
|
||||
assert matches(child_node, child_expected)
|
||||
|
||||
# Step 1 & 2 & 3: Test grandchild node (level 2)
|
||||
grandchild_node = find_one(rendered, Div(data_node_id=grandchild.id))
|
||||
grandchild_expected = Div(
|
||||
style=Contains("padding-left: 40px")
|
||||
)
|
||||
assert matches(grandchild_node, grandchild_expected)
|
||||
|
||||
@@ -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")),
|
||||
|
||||
@@ -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)
|
||||
@@ -63,8 +68,8 @@ class TestMatches:
|
||||
([], [Div(), Span()], "Actual is smaller than expected"),
|
||||
("not a list", [Div(), Span()], "The types are different"),
|
||||
([Div(), Span()], [Div(), 123], "The types are different"),
|
||||
(Div(), Span(), "The elements are different"),
|
||||
([Div(), Span()], [Div(), Div()], "The elements are different"),
|
||||
(Div(), Span(), "The types are different"),
|
||||
([Div(), Span()], [Div(), Div()], "The types are different"),
|
||||
(Div(), Div(attr1="value"), "'attr1' is not found in Actual"),
|
||||
(Div(attr2="value"), Div(attr1="value"), "'attr1' is not found in Actual"),
|
||||
(Div(attr1="value1"), Div(attr1="value2"), "The values are different for 'attr1'"),
|
||||
@@ -80,20 +85,21 @@ class TestMatches:
|
||||
(Div(Span()), Div(Empty()), "The condition 'Empty()' is not satisfied"),
|
||||
(Div(), Div(Span()), "Actual is lesser than expected"),
|
||||
(Div(), Div(123), "Actual is lesser than expected"),
|
||||
(Div(Span()), Div(Div()), "The elements are different"),
|
||||
(Div(Span()), Div(Div()), "The types are different"),
|
||||
(Div(123), Div(Div()), "The types are different"),
|
||||
(Div(123), Div(456), "The values are different"),
|
||||
(Div(Span(), Span()), Div(Span(), Div()), "The elements are different"),
|
||||
(Div(Span(Div())), Div(Span(Span())), "The elements are different"),
|
||||
(Div(Span(), Span()), Div(Span(), Div()), "The types are different"),
|
||||
(Div(Span(Div())), Div(Span(Span())), "The types are different"),
|
||||
(Div(attr1="value1"), Div(AttributeForbidden("attr1")), "condition 'AttributeForbidden(attr1)' is not satisfied"),
|
||||
(Div(123, "value"), TestObject(Dummy, attr1=123, attr2="value2"), "The types are different:"),
|
||||
(Div(123, "value"), TestObject(Dummy, attr1=123, attr2="value2"), "The types are different"),
|
||||
(Dummy(123, "value"), TestObject(Dummy, attr1=123, attr3="value3"), "'attr3' is not found in Actual"),
|
||||
(Dummy(123, "value"), TestObject(Dummy, attr1=123, attr2="value2"), "The values are different for 'attr2'"),
|
||||
(Div(Div(123, "value")), Div(TestObject(Dummy, attr1=123, attr2="value2")), "The types are different:"),
|
||||
(Div(Div(123, "value")), Div(TestObject(Dummy, attr1=123, attr2="value2")), "The types are different"),
|
||||
(Div(Dummy(123, "value")), Div(TestObject(Dummy, attr1=123, attr3="value3")), "'attr3' is not found in Actual"),
|
||||
(Div(Dummy(123, "value")), Div(TestObject(Dummy, attr1=123, attr2="value2")), "are different for 'attr2'"),
|
||||
(Div(123, "value"), TestObject("Dummy", attr1=123, attr2="value2"), "The types are different:"),
|
||||
(Dummy(123, "value"), TestObject("Dummy", attr1=123, attr2=Contains("value2")), "The condition 'Contains(value2)' is not satisfied"),
|
||||
(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"),
|
||||
|
||||
])
|
||||
def test_i_can_detect_errors(self, actual, expected, error_message):
|
||||
|
||||
Reference in New Issue
Block a user