First implementation of bindings

This commit is contained in:
2025-11-09 19:23:18 +01:00
parent b5c1c15198
commit 86dfff812b
51 changed files with 5971 additions and 1080 deletions

1
.gitignore vendored
View File

@@ -6,6 +6,7 @@ app.egg-info
htmlcov
.cache
.venv
src/main.py
tests/settings_from_unit_testing.json
tests/TestDBEngineRoot
tests/*.png

794
README.md
View File

@@ -1,6 +1,7 @@
# MyFastHtml
A utility library designed to simplify the development of FastHtml applications by providing:
- Predefined pages for common functionalities (e.g., authentication, user management).
- A command management system to facilitate client-server interactions.
- Helpers to create interactive controls more easily.
@@ -10,10 +11,12 @@ A utility library designed to simplify the development of FastHtml applications
## Features
- **Dynamic HTML with HTMX**: Simplify dynamic interaction using attributes like `hx-post` and custom routes like `/commands`.
- **Dynamic HTML with HTMX**: Simplify dynamic interaction using attributes like `hx-post` and custom routes like
`/commands`.
- **Command management**: Write server-side logic in Python while abstracting the complexities of HTMX.
- **Binding management**: Mechanism to bind two html element together.
- **Control helpers**: Easily create reusable components like buttons.
- **Predefined Pages (Roadmap)**: Include common pages like login, user management, and customizable dashboards.
- **Login Pages**: Include common pages for login, user management, and customizable dashboards.
> _**Note:** Support for state persistence is currently under construction._
@@ -31,28 +34,59 @@ pip install myfasthtml
## Quick Start
Heres a simple example of creating an **interactive button** linked to a command:
### FastHtml Application
### Example: Button with a Command
To create a simple FastHtml application, you can use the `create_app` function:
```python
from fasthtml.fastapp import fast_app
from fasthtml import serve
from fasthtml.components import *
from myfasthtml.myfastapp import create_app
app, rt = create_app(protect_routes=False)
@rt("/")
def get_homepage():
return Div("Hello, FastHtml!")
if __name__ == "__main__":
serve(port=5002)
```
### Use Commands
```python
from fasthtml import serve
from myfasthtml.controls.helpers import mk_button
from myfasthtml.core.commands import Command
from myfasthtml.controls.button import mk_button
from myfasthtml.myfastapp import create_app
# Define a simple command action
def say_hello():
return "Hello, FastHtml!"
return "Hello, FastHtml!"
# Create the command
hello_command = Command("say_hello", "Responds with a greeting", say_hello)
# Create the app and define a route with a button
app, rt = fast_app(default_hdrs=False)
# Create the app
app, rt = create_app(protect_routes=False)
@rt("/")
def get_homepage():
return mk_button("Click Me!", command=hello_command)
return mk_button("Click Me!", command=hello_command)
if __name__ == "__main__":
serve(port=5002)
```
- When the button is clicked, the `say_hello` command will be executed, and the server will return the response.
@@ -60,34 +94,60 @@ def get_homepage():
---
### Bind components
```python
@dataclass
class Data:
value: str = "Hello World"
checked: bool = False
# Binds an Input with a label
mk.mk(Input(name="input_name"), binding=Binding(data, attr="value").htmx(trigger="input changed")),
mk.mk(Label("Text"), binding=Binding(data, attr="value")),
# Binds a checkbox with a labl
mk.mk(Input(name="checked_name", type="checkbox"), binding=Binding(data, attr="checked")),
mk.mk(Label("Text"), binding=Binding(data, attr="checked")),
```
## Planned Features (Roadmap)
### Predefined Pages
The library will include predefined pages for:
- **Authentication**: Login, signup, password reset.
- **User Management**: User profile and administration pages.
- **Dashboard Templates**: Fully customizable dashboard components.
- **Error Pages**: Detailed and styled error messages (e.g., 404, 500).
### State Persistence
Controls will have their state automatically synchronized between the client and the server. This feature is currently under construction.
Controls will have their state automatically synchronized between the client and the server. This feature is currently
under construction.
---
## Advanced Features
### Command Management System
Commands allow you to simplify frontend/backend interaction. Instead of writing HTMX attributes manually, you can define Python methods and handle them as commands.
Commands allow you to simplify frontend/backend interaction. Instead of writing HTMX attributes manually, you can define
Python methods and handle them as commands.
#### Example
Heres how `Command` simplifies dynamic interaction:
```python
from myfasthtml.core.commands import Command
# Define a command
def custom_action(data):
return f"Received: {data}"
return f"Received: {data}"
my_command = Command("custom", "Handles custom logic", custom_action)
@@ -106,9 +166,659 @@ Use the `get_htmx_params()` method to directly integrate commands into HTML comp
---
## Testing
### TestableElements
#### TestableTextarea
**Use case:** Multi-line text input
**Methods:**
- `send(value)` - Set the textarea value
- `append(text)` - Append text to current value
- `clear()` - Clear the textarea
**Example:**
```python
def test_textarea_binding(user, rt):
@rt("/")
def index():
data = Data("Initial text")
textarea = Textarea(name="message")
label = Label()
mk.manage_binding(textarea, Binding(data))
mk.manage_binding(label, Binding(data))
return textarea, label
user.open("/")
textarea = user.find_element("textarea")
textarea.send("New message")
user.should_see("New message")
textarea.append("\nMore text")
user.should_see("New message\nMore text")
textarea.clear()
user.should_see("")
```
#### TestableSelect
**Use case:** Dropdown selection
**Properties:**
- `is_multiple` - Check if multiple selection is enabled
- `options` - List of available options
**Methods:**
- `select(value)` - Select option by value
- `select_by_text(text)` - Select option by visible text
- `deselect(value)` - Deselect option (multiple select only)
**Example (Single Select):**
```python
def test_select_binding(user, rt):
@rt("/")
def index():
data = Data("option1")
select = Select(
Option("First", value="option1"),
Option("Second", value="option2"),
Option("Third", value="option3"),
name="choice"
)
label = Label()
mk.manage_binding(select, Binding(data))
mk.manage_binding(label, Binding(data))
return select, label
user.open("/")
select_elt = user.find_element("select")
select_elt.select("option2")
user.should_see("option2")
select_elt.select_by_text("Third")
user.should_see("option3")
```
**Example (Multiple Select):**
```python
def test_multiple_select_binding(user, rt):
@rt("/")
def index():
data = ListData(["option1"])
select = Select(
Option("First", value="option1"),
Option("Second", value="option2"),
Option("Third", value="option3"),
name="choices",
multiple=True
)
label = Label()
mk.manage_binding(select, Binding(data))
mk.manage_binding(label, Binding(data))
return select, label
user.open("/")
select_elt = user.find_element("select")
select_elt.select("option2")
user.should_see("['option1', 'option2']")
select_elt.deselect("option1")
user.should_see("['option2']")
```
#### TestableRange
**Use case:** Slider input
**Properties:**
- `min_value` - Minimum value
- `max_value` - Maximum value
- `step` - Step increment
**Methods:**
- `set(value)` - Set slider to specific value (auto-clamped)
- `increase()` - Increase by one step
- `decrease()` - Decrease by one step
**Example:**
```python
def test_range_binding(user, rt):
@rt("/")
def index():
data = NumericData(50)
range_input = Input(
type="range",
name="volume",
min="0",
max="100",
step="10",
value="50"
)
label = Label()
mk.manage_binding(range_input, Binding(data))
mk.manage_binding(label, Binding(data))
return range_input, label
user.open("/")
slider = user.find_element("input[type='range']")
slider.set(75)
user.should_see("75")
slider.increase()
user.should_see("85")
slider.decrease()
user.should_see("75")
```
#### TestableRadio
**Use case:** Radio button (mutually exclusive options)
**Properties:**
- `radio_value` - The value attribute of this radio
- `is_checked` - Check if this radio is selected
**Methods:**
- `select()` - Select this radio button
**Example:**
```python
def test_radio_binding(user, rt):
@rt("/")
def index():
data = Data("option1")
radio1 = Input(type="radio", name="choice", value="option1", checked=True)
radio2 = Input(type="radio", name="choice", value="option2")
radio3 = Input(type="radio", name="choice", value="option3")
label = Label()
mk.manage_binding(radio1, Binding(data))
mk.manage_binding(radio2, Binding(data))
mk.manage_binding(radio3, Binding(data))
mk.manage_binding(label, Binding(data))
return radio1, radio2, radio3, label
user.open("/")
radio2 = user.find_element("input[value='option2']")
radio2.select()
user.should_see("option2")
radio3 = user.find_element("input[value='option3']")
radio3.select()
user.should_see("option3")
```
#### TestableButton
**Use case:** Clickable button with HTMX
**Properties:**
- `text` - Visible text of the button
**Methods:**
- `click()` - Click the button (triggers HTMX if configured)
**Example:**
```python
def test_button_binding(user, rt):
@rt("/")
def index():
data = Data("initial")
button = Button(
"Click me",
hx_post="/update",
hx_vals='{"action": "clicked"}'
)
label = Label()
mk.manage_binding(button, Binding(data))
mk.manage_binding(label, Binding(data))
return button, label
@rt("/update")
def update(action: str):
data = Data("updated")
label = Label()
mk.manage_binding(label, Binding(data))
return label
user.open("/")
button = user.find_element("button")
button.click()
user.should_see("updated")
```
#### TestableDatalist
**Use case:** Input with autocomplete suggestions (combobox)
**Properties:**
- `suggestions` - List of available suggestions
**Methods:**
- `send(value)` - Set input value (any value, not restricted to suggestions)
- `select_suggestion(value)` - Select a value from suggestions
**Example:**
```python
def test_datalist_binding(user, rt):
@rt("/")
def index():
data = Data("")
datalist = Datalist(
Option(value="apple"),
Option(value="banana"),
Option(value="cherry"),
id="fruits"
)
input_elt = Input(name="fruit", list="fruits")
label = Label()
mk.manage_binding(input_elt, Binding(data))
mk.manage_binding(label, Binding(data))
return input_elt, datalist, label
user.open("/")
input_with_list = user.find_element("input[list='fruits']")
# Free text input
input_with_list.send("mango")
user.should_see("mango")
# Select from suggestions
input_with_list.select_suggestion("banana")
user.should_see("banana")
```
## CSS Selectors for Finding Elements
When using `user.find_element()`, use these selectors:
| Component | Selector Example |
|----------------|--------------------------------------------------------|
| Input (text) | `"input[name='field_name']"` or `"input[type='text']"` |
| Checkbox | `"input[type='checkbox']"` |
| Radio | `"input[type='radio']"` or `"input[value='option1']"` |
| Range | `"input[type='range']"` |
| Textarea | `"textarea"` or `"textarea[name='field_name']"` |
| Select | `"select"` or `"select[name='field_name']"` |
| Button | `"button"` or `"button.primary"` |
| Datalist Input | `"input[list='datalist_id']"` |
## Binding
### Overview
This package contains everything needed to implement a complete binding system for FastHTML components.
### Fully Supported Components Summary
| Component | Testable Class | Binding Support |
|-------------------|------------------|-----------------|
| Input (text) | TestableInput | ✅ |
| Checkbox | TestableCheckbox | ✅ |
| Textarea | TestableTextarea | ✅ |
| Select (single) | TestableSelect | ✅ |
| Select (multiple) | TestableSelect | ✅ |
| Range (slider) | TestableRange | ✅ |
| Radio buttons | TestableRadio | ✅ |
| Button | TestableButton | ✅ |
| Input + Datalist | TestableDatalist | ✅ |
### Supported Components
#### 1. Input (Text)
```python
# Methods
input.send(value)
# Binding modes
- ValueChange(default)
- Text
updates
trigger
data
changes
```
#### 2. Checkbox
```python
# Methods
checkbox.check()
checkbox.uncheck()
checkbox.toggle()
# Binding modes
- AttributePresence
- Boolean
data
binding
```
#### 3. Textarea
```python
# Methods
textarea.send(value)
textarea.append(text)
textarea.clear()
# Binding modes
- ValueChange
- Multi - line
text
support
```
#### 4. Select (Single)
```python
# Methods
select.select(value)
select.select_by_text(text)
# Properties
select.options # List of available options
select.is_multiple # False for single select
# Binding modes
- ValueChange
- String
value
binding
```
#### 5. Select (Multiple)
```python
# Methods
select.select(value)
select.deselect(value)
select.select_by_text(text)
# Properties
select.options
select.is_multiple # True for multiple select
# Binding modes
- ValueChange
- List
data
binding
```
#### 6. Range (Slider)
```python
# Methods
range.set(value) # Auto-clamps to min/max
range.increase()
range.decrease()
# Properties
range.min_value
range.max_value
range.step
# Binding modes
- ValueChange
- Numeric
data
binding
```
#### 7. Radio Buttons
```python
# Methods
radio.select()
# Properties
radio.radio_value # Value attribute
radio.is_checked
# Binding modes
- ValueChange
- String
value
binding
- Mutually
exclusive
group
behavior
```
#### 8. Button
```python
# Methods
button.click()
# Properties
button.text # Visible button text
# Binding modes
- Triggers
HTMX
requests
- Can
update
bindings
via
server
response
```
#### 9. Input + Datalist (Combobox)
```python
# Methods
datalist.send(value) # Any value
datalist.select_suggestion(value) # From suggestions
# Properties
datalist.suggestions # Available options
# Binding modes
- ValueChange
- Hybrid: free
text + suggestions
```
### Architecture Overview
#### Three-Phase Binding Lifecycle
```python
# Phase 1: Create (inactive)
binding = Binding(data, "value")
# Phase 2: Configure + Activate
binding.bind_ft(element, name="input", attr="value")
# Phase 3: Deactivate (cleanup)
binding.deactivate()
```
#### Data Flow
```
User Input → HTMX Component → HTMX Request → Binding.update()
setattr(data, attr, value)
Observable triggers
Binding.notify()
Update all bound UI elements
```
### Quick Reference
#### Creating a Binding
```python
# Simple binding
binding = Binding(data, "value").bind_ft(
Input(name="input"),
name="input",
attr="value"
)
# With detection and update modes
binding = Binding(data, "checked").bind_ft(
Input(type="checkbox", name="check"),
name="check",
attr="checked",
detection_mode=DetectionMode.AttributePresence,
update_mode=UpdateMode.AttributePresence
)
# With data converter
binding = Binding(data, "value").bind_ft(
Input(type="checkbox", name="check"),
name="check",
attr="checked",
data_converter=BooleanConverter()
)
```
#### Testing a Component
```python
def test_component_binding(user, rt):
@rt("/")
def index():
data = Data("initial")
component = Component(name="field")
label = Label()
mk.manage_binding(component, Binding(data))
mk.manage_binding(label, Binding(data))
return component, label
user.open("/")
user.should_see("initial")
testable = user.find_element("selector")
testable.method("new value")
user.should_see("new value")
```
#### Managing Binding Lifecycle
```python
# Create
binding = Binding(data, "value")
# Activate (via bind_ft)
binding.bind_ft(element, name="field")
# Deactivate
binding.deactivate()
# Reactivate with new element
binding.bind_ft(new_element, name="field")
```
### Pattern 1: Bidirectional Binding
All components support bidirectional binding:
- UI changes update the data object
- Data object changes update the UI (via Label or other bound components)
```python
input_elt = Input(name="field")
label_elt = Label()
mk.manage_binding(input_elt, Binding(data))
mk.manage_binding(label_elt, Binding(data))
# Change via UI
testable_input.send("new value")
# Label automatically updates to show "new value"
```
### Pattern 2: Multiple Components, Same Data
Multiple different components can bind to the same data:
```python
input_elt = Input(name="input")
textarea_elt = Textarea(name="textarea")
label_elt = Label()
# All bind to the same data object
mk.manage_binding(input_elt, Binding(data))
mk.manage_binding(textarea_elt, Binding(data))
mk.manage_binding(label_elt, Binding(data))
# Changing any component updates all others
```
### Pattern 3: Component Without Name
Components without a name attribute won't trigger updates but won't crash:
```python
input_elt = Input() # No name attribute
label_elt = Label()
mk.manage_binding(label_elt, Binding(data))
# Input won't trigger updates, but label will still display data
```
## Contributing
We welcome contributions! To get started:
1. Fork the repository.
2. Create a feature branch.
3. Submit a pull request with clear descriptions of your changes.
@@ -144,26 +854,32 @@ MyFastHtml
### Notable Classes and Methods
#### 1. `Command`
Represents a backend action with server communication.
- **Attributes:**
- `id`: Unique identifier for the command.
- `name`: Command name (e.g., `say_hello`).
- `description`: Description of the command.
- `id`: Unique identifier for the command.
- `name`: Command name (e.g., `say_hello`).
- `description`: Description of the command.
- **Method:** `get_htmx_params()` generates HTMX attributes.
#### 2. `mk_button`
Simplifies the creation of interactive buttons linked to commands.
- **Arguments:**
- `element` (str): The label for the button.
- `command` (Command): Command associated with the button.
- `kwargs`: Additional button attributes.
- `element` (str): The label for the button.
- `command` (Command): Command associated with the button.
- `kwargs`: Additional button attributes.
#### 3. `LoginPage`
Predefined login page that provides a UI template ready for integration.
- **Constructor Parameters:**
- `settings_manager`: Configuration/settings object.
- `error_message`: Optional error message to display.
- `success_message`: Optional success message to display.
- `settings_manager`: Configuration/settings object.
- `error_message`: Optional error message to display.
- `success_message`: Optional success message to display.
---
@@ -177,6 +893,40 @@ Predefined login page that provides a UI template ready for integration.
No custom exceptions defined yet. (Placeholder for future use.)
## Troubleshooting
### Issue: "No element found matching selector"
**Cause:** Incorrect CSS selector or element not in DOM
**Solution:** Check the HTML output and adjust selector
```python
# Debug: Print the HTML
print(user.get_content())
# Try different selectors
user.find_element("textarea")
user.find_element("textarea[name='message']")
```
### Issue: TestableControl has no attribute 'send'
**Cause:** Wrong testable class returned by factory
**Solution:** Verify factory method is updated correctly
### Issue: AttributeError on TestableTextarea
**Cause:** Class not properly inheriting from TestableControl
**Solution:** Check class hierarchy and imports
### Issue: Select options not found
**Cause:** `_update_fields()` not parsing select correctly
**Solution:** Verify TestableElement properly parses select/option tags
## Relase History

View File

@@ -6,11 +6,14 @@ apswutils==0.1.0
argon2-cffi==25.1.0
argon2-cffi-bindings==25.1.0
beautifulsoup4==4.14.2
build==1.3.0
certifi==2025.10.5
cffi==2.0.0
charset-normalizer==3.4.4
click==8.3.0
cryptography==46.0.3
dnspython==2.8.0
docutils==0.22.2
ecdsa==0.19.1
email-validator==2.3.0
fastapi==0.120.0
@@ -20,13 +23,25 @@ h11==0.16.0
httpcore==1.0.9
httptools==0.7.1
httpx==0.28.1
id==1.5.0
idna==3.11
iniconfig==2.3.0
itsdangerous==2.2.0
-e git+ssh://git@sheerka.synology.me:1010/kodjo/MyAuth.git@0138ac247a4a53dc555b94ec13119eba16e1db68#egg=myauth
jaraco.classes==3.4.0
jaraco.context==6.0.1
jaraco.functools==4.3.0
jeepney==0.9.0
keyring==25.6.0
markdown-it-py==4.0.0
mdurl==0.1.2
more-itertools==10.8.0
myauth==0.2.0
myutils==0.4.0
nh3==0.3.1
oauthlib==3.3.1
packaging==25.0
passlib==1.7.4
pipdeptree==2.29.0
pluggy==1.6.0
pyasn1==0.6.1
pycparser==2.23
@@ -34,6 +49,7 @@ pydantic==2.12.3
pydantic-settings==2.11.0
pydantic_core==2.41.4
Pygments==2.19.2
pyproject_hooks==1.2.0
pytest==8.4.2
python-dateutil==2.9.0.post0
python-dotenv==1.1.1
@@ -41,13 +57,21 @@ python-fasthtml==0.12.30
python-jose==3.5.0
python-multipart==0.0.20
PyYAML==6.0.3
readme_renderer==44.0
requests==2.32.5
requests-toolbelt==1.0.0
rfc3986==2.0.0
rich==14.2.0
rsa==4.9.1
SecretStorage==3.4.0
six==1.17.0
sniffio==1.3.1
soupsieve==2.8
starlette==0.48.0
twine==6.2.0
typing-inspection==0.4.2
typing_extensions==4.15.0
urllib3==2.5.0
uvicorn==0.38.0
uvloop==0.22.1
watchfiles==1.1.1

View File

@@ -0,0 +1,15 @@
.mf-icon-20 {
width: 20px;
min-width: 20px;
height: 20px;
margin-top: auto;
margin-bottom: auto;
}
.mf-icon-16 {
width: 16px;
min-width: 16px;
height: 16px;
margin-top: auto;
margin-bottom: 4px;
}

View File

@@ -1,11 +0,0 @@
from fasthtml.components import *
from myfasthtml.core.commands import Command
def mk_button(element, command: Command = None, **kwargs):
if command is None:
return Button(element, **kwargs)
htmx = command.get_htmx_params()
return Button(element, **htmx, **kwargs)

View File

@@ -0,0 +1,55 @@
from fasthtml.components import *
from myfasthtml.core.bindings import Binding
from myfasthtml.core.commands import Command
from myfasthtml.core.utils import merge_classes
class mk:
@staticmethod
def button(element, command: Command = None, binding: Binding = None, **kwargs):
return mk.mk(Button(element, **kwargs), command=command, binding=binding)
@staticmethod
def icon(icon, size=20,
can_select=True,
can_hover=False,
cls='',
command: Command = None,
binding: Binding = None,
**kwargs):
merged_cls = merge_classes(f"mf-icon-{size}",
'icon-btn' if can_select else '',
'mmt-btn' if can_hover else '',
cls,
kwargs)
return mk.mk(Div(icon, cls=merged_cls, **kwargs), command=command, binding=binding)
@staticmethod
def manage_command(ft, command: Command):
if command:
ft = command.bind_ft(ft)
return ft
@staticmethod
def manage_binding(ft, binding: Binding, ft_attr=None, init_binding=True):
if not binding:
return ft
binding.bind_ft(ft, ft_attr)
if init_binding:
binding.init()
# as it is the first binding, remove the hx-swap-oob
if "hx-swap-oob" in ft.attrs:
del ft.attrs["hx-swap-oob"]
return ft
@staticmethod
def mk(ft, command: Command = None, binding: Binding = None, init_binding=True):
ft = mk.manage_command(ft, command)
ft = mk.manage_binding(ft, binding, init_binding=init_binding)
return ft

View File

@@ -0,0 +1,462 @@
import logging
import uuid
from enum import Enum
from typing import Optional, Any
from fasthtml.components import Option
from fasthtml.fastapp import fast_app
from myutils.observable import make_observable, bind, collect_return_values, unbind
from myfasthtml.core.constants import Routes, ROUTE_ROOT
from myfasthtml.core.utils import get_default_attr, get_default_ft_attr, is_checkbox, is_radio, is_select, is_datalist
bindings_app, bindings_rt = fast_app()
logger = logging.getLogger("Bindings")
class UpdateMode(Enum):
ValueChange = "ValueChange"
AttributePresence = "AttributePresence"
SelectValueChange = "SelectValueChange"
DatalistListChange = "DatalistListChange"
class DetectionMode(Enum):
ValueChange = "ValueChange"
AttributePresence = "AttributePresence"
SelectValueChange = "SelectValueChange"
class AttrChangedDetection:
"""
Base class for detecting changes in an attribute of a data object.
The when a modification is triggered we can
* Search for the attribute that is modified (usual case)
* Look if the attribute is present in the data object (for example when a checkbox is toggled)
"""
def __init__(self, attr):
self.attr = attr
def matches(self, values):
pass
class ValueChangedDetection(AttrChangedDetection):
"""
Search for the attribute that is modified.
"""
def matches(self, values):
for key, value in values.items():
if key == self.attr:
return True, value
return False, None
class SelectValueChangedDetection(AttrChangedDetection):
"""
Search for the attribute that is modified.
"""
def matches(self, values):
for key, value in values.items():
if key == self.attr:
return True, value
return True, []
class AttrPresentDetection(AttrChangedDetection):
"""
Search if the attribute is present in the data object.
"""
def matches(self, values):
return True, values.get(self.attr, None)
class FtUpdate:
def update(self, ft, ft_name, ft_attr, old, new, converter):
pass
class ValueChangeFtUpdate(FtUpdate):
def update(self, ft, ft_name, ft_attr, old, new, converter):
# simple mode, just update the text or the attribute
new_to_use = converter.convert(new) if converter else new
if ft_attr is None:
ft.children = (new_to_use,)
else:
ft.attrs[ft_attr] = new_to_use
return ft
class SelectValueChangeFtUpdate(FtUpdate):
def update(self, ft, ft_name, ft_attr, old, new, converter):
# simple mode, just update the text or the attribute
new_to_use = converter.convert(new) if converter else new
new_to_use = [new_to_use] if not isinstance(new_to_use, list) else new_to_use
for child in [c for c in ft.children if c.tag == "option"]:
if child.attrs.get("value", None) in new_to_use:
child.attrs["selected"] = "true"
else:
child.attrs.pop("selected", None)
return ft
class DatalistListChangeFtUpdate(FtUpdate):
def update(self, ft, ft_name, ft_attr, old, new, converter):
new_to_use = converter.convert(new) if converter else new
ft.children = tuple([Option(value=v) for v in new_to_use])
return ft
class AttributePresenceFtUpdate(FtUpdate):
def update(self, ft, ft_name, ft_attr, old, new, converter):
# attribute presence mode, toggle the attribute (add or remove it)
new_to_use = converter.convert(new) if converter else new
if ft_attr is None:
ft.children = (bool(new_to_use),)
else:
ft.attrs[ft_attr] = "true" if new_to_use else None # FastHtml auto remove None attributes
return ft
class DataConverter:
def convert(self, data):
pass
class BooleanConverter(DataConverter):
def convert(self, data):
if data is None:
return False
if isinstance(data, int):
return data != 0
if str(data).lower() in ("true", "yes", "on"):
return True
return False
class ListConverter(DataConverter):
def convert(self, data):
if data is None:
return []
if isinstance(data, str):
return data.split("\n")
if isinstance(data, (list, set, tuple)):
return data
return [data]
class RadioConverter(DataConverter):
def __init__(self, radio_value):
self.radio_value = radio_value
def convert(self, data):
return data == self.radio_value
class Binding:
def __init__(self, data: Any, attr: str = None, converter: DataConverter = None):
"""
Creates a new binding object between a data object and an HTML element.
The binding is not active until bind_ft() is called.
Args:
data: Object used as a pivot
attr: Attribute of the data object to bind
"""
self.id = uuid.uuid4()
self.htmx_extra = {}
self.data = data
self.data_attr = attr or get_default_attr(data)
self.data_converter = converter
# UI-related attributes (configured later via bind_ft)
self.ft = None
self.ft_name = None
self.ft_attr = None
self.detection_mode = DetectionMode.ValueChange
self.update_mode = UpdateMode.ValueChange
# Strategy objects (configured later)
self._detection = None
self._update = None
# Activation state
self._is_active = False
def bind_ft(self,
ft,
attr=None,
name=None,
data_converter: DataConverter = None,
detection_mode: DetectionMode = None,
update_mode: UpdateMode = None):
"""
Configure the UI element and activate the binding.
Args:
ft: HTML element to bind to
name: Name of the HTML element (sent by the form)
attr: Attribute of the HTML element to bind to
data_converter: Optional converter for data transformation
detection_mode: How to detect changes from UI
update_mode: How to update the UI element
Returns:
self for method chaining
"""
# Deactivate if already active
if self._is_active:
self.deactivate()
if ft.tag in ["input", "textarea", "select"]:
# I must not force the htmx
if {"hx-post", "hx_post"} & set(ft.attrs.keys()):
raise ValueError(f"Binding '{self.id}': htmx post already set on input.")
# update the component to post on the correct route input and forms only
htmx = self.get_htmx_params()
ft.attrs |= htmx
# Configure UI elements
self.ft = self._safe_ft(ft)
self.ft_name = name or ft.attrs.get("name")
self.ft_attr = attr or get_default_ft_attr(ft)
if is_checkbox(ft):
default_data_converter = self.data_converter or BooleanConverter()
default_detection_mode = DetectionMode.AttributePresence
default_update_mode = UpdateMode.AttributePresence
elif is_radio(ft):
default_data_converter = self.data_converter or RadioConverter(ft.attrs["value"])
default_detection_mode = DetectionMode.ValueChange
default_update_mode = UpdateMode.AttributePresence
elif is_select(ft):
default_data_converter = self.data_converter
default_detection_mode = DetectionMode.SelectValueChange
default_update_mode = UpdateMode.SelectValueChange
elif is_datalist(ft):
default_data_converter = self.data_converter or ListConverter()
default_detection_mode = DetectionMode.SelectValueChange
default_update_mode = UpdateMode.DatalistListChange
else:
default_data_converter = self.data_converter
default_detection_mode = DetectionMode.ValueChange
default_update_mode = UpdateMode.ValueChange
# Update optional parameters if provided
self.data_converter = data_converter or default_data_converter
self.detection_mode = detection_mode or default_detection_mode
self.update_mode = update_mode or default_update_mode
# Create strategy objects
self._detection = self._factory(self.detection_mode)
self._update = self._factory(self.update_mode)
# Activate the binding
self.activate()
return self
def get_htmx_params(self):
return self.htmx_extra | {
"hx-post": f"{ROUTE_ROOT}{Routes.Bindings}",
"hx-vals": f'{{"b_id": "{self.id}"}}',
}
def init(self):
"""
Initialise the UI element with the value of the data
:return:
"""
old_value = None # to complicated to retrieve as it depends on the nature of self.ft
new_value = getattr(self.data, self.data_attr)
self.notify(old_value, new_value)
return self
def notify(self, old, new):
"""
Callback when the data attribute changes.
Updates the UI element accordingly.
Args:
old: Previous value
new: New value
Returns:
Updated ft element
"""
if not self._is_active:
logger.warning(f"Binding '{self.id}' received notification but is not active")
return None
logger.debug(f"Binding '{self.id}': Changing from '{old}' to '{new}'")
self.ft = self._update.update(self.ft, self.ft_name, self.ft_attr, old, new, self.data_converter)
self.ft.attrs["hx-swap-oob"] = "true"
return self.ft
def update(self, values: dict):
"""
Called by the FastHTML router when a request is received.
:param values:
:return: the list of updated elements (all elements that are bound to this binding)
"""
logger.debug(f"Binding '{self.id}': Updating with {values=}.")
matches, value = self._detection.matches(values)
if matches:
setattr(self.data, self.data_attr, value)
res = collect_return_values(self.data)
return res
else:
logger.debug(f"Nothing to trigger in {values}.")
return None
def activate(self):
"""
Activate the binding by setting up observers and registering it.
Should only be called after the binding is fully configured.
Raises:
ValueError: If the binding is not fully configured
"""
if self._is_active:
logger.warning(f"Binding '{self.id}' is already active")
return
# Validate configuration
self._validate_configuration()
# Setup observable
make_observable(self.data)
bind(self.data, self.data_attr, self.notify)
# Register in manager
BindingsManager.register(self)
# Mark as active
self._is_active = True
logger.debug(f"Binding '{self.id}' activated for {self.data_attr}")
def deactivate(self):
"""
Deactivate the binding by removing observers and unregistering it.
Can be called multiple times safely.
"""
if not self._is_active:
logger.debug(f"Binding '{self.id}' is not active, nothing to deactivate")
return
# Remove observer
unbind(self.data, self.data_attr, self.notify)
# Unregister from manager
BindingsManager.unregister(self.id)
# Mark as inactive
self._is_active = False
logger.debug(f"Binding '{self.id}' deactivated")
@staticmethod
def _safe_ft(ft):
"""
Make sure the ft has an id.
:param ft:
:return:
"""
if ft is None:
return None
if ft.attrs.get("id", None) is None:
ft.attrs["id"] = str(uuid.uuid4())
return ft
def _factory(self, mode):
if mode == DetectionMode.ValueChange:
return ValueChangedDetection(self.ft_name)
elif mode == DetectionMode.AttributePresence:
return AttrPresentDetection(self.ft_name)
elif mode == DetectionMode.SelectValueChange:
return SelectValueChangedDetection(self.ft_name)
elif mode == UpdateMode.ValueChange:
return ValueChangeFtUpdate()
elif mode == UpdateMode.AttributePresence:
return AttributePresenceFtUpdate()
elif mode == UpdateMode.SelectValueChange:
return SelectValueChangeFtUpdate()
elif mode == UpdateMode.DatalistListChange:
return DatalistListChangeFtUpdate()
else:
raise ValueError(f"Invalid detection mode: {mode}")
def _validate_configuration(self):
"""
Validate that the binding is fully configured before activation.
Raises:
ValueError: If required configuration is missing
"""
if self.ft is None:
raise ValueError(f"Binding '{self.id}': ft element is required")
# if self.ft_name is None:
# raise ValueError(f"Binding '{self.id}': ft_name is required")
if self._detection is None:
raise ValueError(f"Binding '{self.id}': detection strategy not initialized")
if self._update is None:
raise ValueError(f"Binding '{self.id}': update strategy not initialized")
def htmx(self, trigger=None):
if trigger:
self.htmx_extra["hx-trigger"] = trigger
return self
class BindingsManager:
bindings = {}
@staticmethod
def register(binding: Binding):
BindingsManager.bindings[str(binding.id)] = binding
@staticmethod
def unregister(binding_id: str):
"""
Unregister a binding from the manager.
Args:
binding_id: ID of the binding to unregister
"""
if str(binding_id) in BindingsManager.bindings:
del BindingsManager.bindings[str(binding_id)]
@staticmethod
def get_binding(binding_id: str) -> Optional[Binding]:
return BindingsManager.bindings.get(str(binding_id))
@staticmethod
def reset():
return BindingsManager.bindings.clear()

View File

@@ -1,14 +1,9 @@
import logging
import uuid
from typing import Optional
from fasthtml.fastapp import fast_app
from myutils.observable import NotObservableError, ObservableEvent, add_event_listener, remove_event_listener
from myfasthtml.core.constants import Routes, ROUTE_ROOT
from myfasthtml.core.utils import mount_if_not_exists
commands_app, commands_rt = fast_app()
logger = logging.getLogger("Commands")
class BaseCommand:
@@ -32,18 +27,62 @@ class BaseCommand:
self.id = uuid.uuid4()
self.name = name
self.description = description
self._htmx_extra = {}
self._bindings = []
# register the command
CommandsManager.register(self)
def get_htmx_params(self):
return {
return self._htmx_extra | {
"hx-post": f"{ROUTE_ROOT}{Routes.Commands}",
"hx-vals": f'{{"c_id": "{self.id}"}}',
}
def execute(self):
raise NotImplementedError
def htmx(self, target="this", swap="innerHTML"):
if target is None:
self._htmx_extra["hx-swap"] = "none"
elif target != "this":
self._htmx_extra["hx-target"] = target
if swap is None:
self._htmx_extra["hx-swap"] = "none"
elif swap != "innerHTML":
self._htmx_extra["hx-swap"] = swap
return self
def bind_ft(self, ft):
"""
Update the FT with the command's HTMX parameters.
:param ft:
:return:
"""
htmx = self.get_htmx_params()
ft.attrs |= htmx
return ft
def bind(self, data):
"""
Attach a binding to the command.
When done, if a binding is triggered during the execution of the command,
the results of the binding will be passed to the command's execute() method.
:param data:
:return:
"""
if not hasattr(data, '_listeners'):
raise NotObservableError(
f"Object must be made observable with make_observable() before binding"
)
self._bindings.append(data)
# by default, remove the swap on the attached element when binding is used
self._htmx_extra["hx-swap"] = "none"
return self
class Command(BaseCommand):
@@ -72,7 +111,26 @@ class Command(BaseCommand):
self.kwargs = kwargs
def execute(self):
return self.callback(*self.args, **self.kwargs)
ret_from_bindings = []
def binding_result_callback(attr, old, new, results):
ret_from_bindings.extend(results)
for data in self._bindings:
add_event_listener(ObservableEvent.AFTER_PROPERTY_CHANGE, data, "", binding_result_callback)
ret = self.callback(*self.args, **self.kwargs)
for data in self._bindings:
remove_event_listener(ObservableEvent.AFTER_PROPERTY_CHANGE, data, "", binding_result_callback)
if not ret_from_bindings:
return ret
if isinstance(ret, list):
return ret + ret_from_bindings
else:
return [ret] + ret_from_bindings
def __str__(self):
return f"Command({self.name})"
@@ -92,31 +150,3 @@ class CommandsManager:
@staticmethod
def reset():
return CommandsManager.commands.clear()
@commands_rt(Routes.Commands)
def post(session: str, c_id: str):
"""
Default routes for all commands.
:param session:
:param c_id:
:return:
"""
logger.debug(f"Entering {Routes.Commands} with {session=}, {c_id=}")
command = CommandsManager.get_command(c_id)
if command:
return command.execute()
raise ValueError(f"Command with ID '{c_id}' not found.")
def mount_commands(app):
"""
Mounts the commands_app to the given application instance if the route does not already exist.
:param app: The application instance to which the commands_app will be mounted.
:type app: Any
:return: Returns the result of the mount operation performed by mount_if_not_exists.
:rtype: Any
"""
return mount_if_not_exists(app, ROUTE_ROOT, commands_app)

View File

@@ -1,4 +1,5 @@
ROUTE_ROOT = "/myfasthtml"
class Routes:
Commands = "/commands"
Commands = "/commands"
Bindings = "/bindings"

View File

@@ -1,852 +0,0 @@
import dataclasses
import json
import uuid
from dataclasses import dataclass
from typing import Self
from bs4 import BeautifulSoup, Tag
from fastcore.xml import FT, to_xml
from fasthtml.common import FastHTML
from starlette.responses import Response
from starlette.testclient import TestClient
from myfasthtml.core.commands import mount_commands
@dataclass
class MyFT:
tag: str
attrs: dict
children: list['MyFT'] = dataclasses.field(default_factory=list)
text: str | None = None
class TestableElement:
"""
Represents an HTML element that can be interacted with in tests.
This class will be used for future interactions like clicking elements
or verifying element properties.
"""
def __init__(self, client, source):
"""
Initialize a testable element.
Args:
client: The MyTestClient instance.
ft: The FastHTML element representation.
"""
self.client = client
if isinstance(source, str):
self.html_fragment = source
tag = BeautifulSoup(source, 'html.parser').find()
self.ft = MyFT(tag.name, tag.attrs)
elif isinstance(source, Tag):
self.html_fragment = str(source)
self.ft = MyFT(source.name, source.attrs)
elif isinstance(source, FT):
self.ft = source
self.html_fragment = to_xml(source).strip()
else:
raise ValueError(f"Invalid source '{source}' for TestableElement.")
def click(self):
"""Click the element (to be implemented)."""
return self._send_htmx_request()
def matches(self, ft):
"""Check if element matches given FastHTML element (to be implemented)."""
pass
def _send_htmx_request(self, json_data: dict | None = None, data: dict | None = None) -> Response:
"""
Simulates an HTMX request in Python for unit testing.
This function reads the 'hx-*' attributes from the FastHTML object
to determine the HTTP method, URL, headers, and body of the request,
then executes it via the TestClient.
Args:
data: (Optional) A dict for form data
(sends as 'application/x-www-form-urlencoded').
json_data: (Optional) A dict for JSON data
(sends as 'application/json').
Takes precedence over 'hx_vals'.
Returns:
The Response object from the simulated request.
"""
# The essential header for FastHTML (and HTMX) to identify the request
headers = {"HX-Request": "true"}
method = "GET" # HTMX defaults to GET if not specified
url = None
verbs = {
'hx_get': 'GET',
'hx_post': 'POST',
'hx_put': 'PUT',
'hx_delete': 'DELETE',
'hx_patch': 'PATCH',
}
# .props contains the kwargs passed to the object (e.g., hx_post="/url")
element_attrs = self.ft.attrs or {}
# Build the attributes
for key, value in element_attrs.items():
# sanitize the key
key = key.lower().strip()
if key.startswith('hx-'):
key = 'hx_' + key[3:]
if key in verbs:
# Verb attribute: defines the method and URL
method = verbs[key]
url = str(value)
elif key == 'hx_vals':
# hx_vals defines the JSON body, if not already provided by the test
if json_data is None:
if isinstance(value, str):
json_data = json.loads(value)
elif isinstance(value, dict):
json_data = value
elif key.startswith('hx_'):
# Any other hx_* attribute is converted to an HTTP header
# e.g.: 'hx_target' -> 'HX-Target'
header_name = '-'.join(p.capitalize() for p in key.split('_'))
headers[header_name] = str(value)
# Sanity check
if url is None:
raise ValueError(
f"The <{self.ft.tag}> element has no HTMX verb attribute "
"(e.g., hx_get, hx_post) to define a URL."
)
# Send the request
return self.client.send_request(method, url, headers=headers, data=data, json_data=json_data)
def _support_htmx(self):
"""Check if the element supports HTMX."""
return ('hx_get' in self.ft.attrs or
'hx-get' in self.ft.attrs or
'hx_post' in self.ft.attrs or
'hx-post' in self.ft.attrs)
class TestableForm(TestableElement):
"""
Represents an HTML form that can be filled and submitted in tests.
"""
def __init__(self, client, source):
"""
Initialize a testable form.
Args:
client: The MyTestClient instance.
source: The source HTML string containing a form.
"""
super().__init__(client, source)
self.form = BeautifulSoup(self.html_fragment, 'html.parser').find('form')
self.fields_mapping = {} # link between the input label and the input name
self.fields = {} # field name; field value
self.select_fields = {} # list of possible options for 'select' input fields
self._update_fields_mapping()
self.update_fields()
def update_fields(self):
"""
Update the fields dictionary with current form values and their proper types.
This method processes all input and select elements in the form:
- Determines the appropriate Python type (str, int, float, bool) based on
the HTML input type attribute and/or the value itself
- For select elements, populates self.select_fields with available options
- Stores the final typed values in self.fields
Type conversion priority:
1. HTML type attribute (checkbox → bool, number → int/float, etc.)
2. Value analysis fallback for ambiguous types (text/hidden/absent type)
"""
self.fields = {}
self.select_fields = {}
# Process input fields
for input_field in self.form.find_all('input'):
name = input_field.get('name')
if not name:
continue
input_type = input_field.get('type', 'text').lower()
raw_value = input_field.get('value', '')
# Type conversion based on input type
if input_type == 'checkbox':
# Checkbox: bool based on 'checked' attribute
self.fields[name] = input_field.has_attr('checked')
elif input_type == 'radio':
# Radio: str value (only if checked)
if input_field.has_attr('checked'):
self.fields[name] = raw_value
elif name not in self.fields:
# If no radio is checked yet, don't set a default
pass
elif input_type == 'number':
# Number: int or float based on value
self.fields[name] = self._convert_number(raw_value)
else:
# Other types (text, hidden, email, password, etc.): analyze value
self.fields[name] = self._convert_value(raw_value)
# Process select fields
for select_field in self.form.find_all('select'):
name = select_field.get('name')
if not name:
continue
# Extract all options
options = []
selected_value = None
for option in select_field.find_all('option'):
option_value = option.get('value', option.get_text(strip=True))
option_text = option.get_text(strip=True)
options.append({
'value': option_value,
'text': option_text
})
# Track selected option
if option.has_attr('selected'):
selected_value = option_value
# Store options list
self.select_fields[name] = options
# Store selected value (or first option if none selected)
if selected_value is not None:
self.fields[name] = selected_value
elif options:
self.fields[name] = options[0]['value']
def fill(self, **kwargs):
"""
Fill the form with the given data.
Args:
**kwargs: Field names and their values to fill in the form.
"""
for name, value in kwargs.items():
field_name = self.translate(name)
if field_name not in self.fields:
raise ValueError(f"Invalid field name '{name}'.")
self.fields[self.translate(name)] = value
def submit(self):
"""
Submit the form.
This method handles both HTMX-enabled forms and classic HTML form submissions:
- If the form supports HTMX (has hx_post, hx_get, etc.), uses HTMX request
- Otherwise, simulates a classic browser form submission using the form's
action and method attributes
Returns:
The response from the form submission.
Raises:
ValueError: If the form has no action attribute for classic submission.
"""
# Check if the form supports HTMX
if self._support_htmx():
return self._send_htmx_request(data=self.fields)
# Classic form submission
action = self.form.get('action')
if not action or action.strip() == '':
raise ValueError(
"The form has no 'action' attribute. "
"Cannot submit a classic form without a target URL."
)
method = self.form.get('method', 'post').upper()
# Prepare headers for classic form submission
headers = {
"Content-Type": "application/x-www-form-urlencoded"
}
# Send the request via the client
return self.client.send_request(
method=method,
url=action,
headers=headers,
data=self.fields
)
def translate(self, field):
return self.fields_mapping.get(field, field)
def _update_fields_mapping(self):
"""
Build a mapping between label text and input field names.
This method finds all labels in the form and associates them with their
corresponding input fields using the following priority order:
1. Explicit association via 'for' attribute matching input 'id'
2. Implicit association (label contains the input)
3. Parent-level association with 'for'/'id'
4. Proximity association (siblings in same parent)
5. No label (use input name as key)
The mapping is stored in self.fields_mapping as {label_text: input_name}.
For inputs without a name, the id is used. If neither exists, a generic
key like "unnamed_0" is generated.
"""
self.fields_mapping = {}
processed_inputs = set()
unnamed_counter = 0
# Get all inputs in the form
all_inputs = self.form.find_all('input')
# Priority 1 & 2: Explicit association (for/id) and implicit (nested)
for label in self.form.find_all('label'):
label_text = label.get_text(strip=True)
# Check for explicit association via 'for' attribute
label_for = label.get('for')
if label_for:
input_field = self.form.find('input', id=label_for)
if input_field:
input_name = self._get_input_identifier(input_field, unnamed_counter)
if input_name.startswith('unnamed_'):
unnamed_counter += 1
self.fields_mapping[label_text] = input_name
processed_inputs.add(id(input_field))
continue
# Check for implicit association (label contains input)
input_field = label.find('input')
if input_field:
input_name = self._get_input_identifier(input_field, unnamed_counter)
if input_name.startswith('unnamed_'):
unnamed_counter += 1
self.fields_mapping[label_text] = input_name
processed_inputs.add(id(input_field))
continue
# Priority 3 & 4: Parent-level associations
for label in self.form.find_all('label'):
label_text = label.get_text(strip=True)
# Skip if this label was already processed
if label_text in self.fields_mapping:
continue
parent = label.parent
if parent:
input_found = False
# Priority 3: Look for sibling input with matching for/id
label_for = label.get('for')
if label_for:
for sibling in parent.find_all('input'):
if sibling.get('id') == label_for and id(sibling) not in processed_inputs:
input_name = self._get_input_identifier(sibling, unnamed_counter)
if input_name.startswith('unnamed_'):
unnamed_counter += 1
self.fields_mapping[label_text] = input_name
processed_inputs.add(id(sibling))
input_found = True
break
# Priority 4: Fallback to proximity if no input found yet
if not input_found:
for sibling in parent.find_all('input'):
if id(sibling) not in processed_inputs:
input_name = self._get_input_identifier(sibling, unnamed_counter)
if input_name.startswith('unnamed_'):
unnamed_counter += 1
self.fields_mapping[label_text] = input_name
processed_inputs.add(id(sibling))
break
# Priority 5: Inputs without labels
for input_field in all_inputs:
if id(input_field) not in processed_inputs:
input_name = self._get_input_identifier(input_field, unnamed_counter)
if input_name.startswith('unnamed_'):
unnamed_counter += 1
self.fields_mapping[input_name] = input_name
@staticmethod
def _get_input_identifier(input_field, counter):
"""
Get the identifier for an input field.
Args:
input_field: The BeautifulSoup Tag object representing the input.
counter: Current counter for unnamed inputs.
Returns:
The input name, id, or a generated "unnamed_X" identifier.
"""
if input_field.get('name'):
return input_field['name']
elif input_field.get('id'):
return input_field['id']
else:
return f"unnamed_{counter}"
@staticmethod
def _convert_number(value):
"""
Convert a string value to int or float.
Args:
value: String value to convert.
Returns:
int, float, or empty string if conversion fails.
"""
if not value or value.strip() == '':
return ''
try:
# Try float first to detect decimal numbers
if '.' in value or 'e' in value.lower():
return float(value)
else:
return int(value)
except ValueError:
return value
@staticmethod
def _convert_value(value):
"""
Analyze and convert a value to its appropriate type.
Conversion priority:
1. Boolean keywords (true/false)
2. Float (contains decimal point)
3. Int (numeric)
4. Empty string
5. String (default)
Args:
value: String value to convert.
Returns:
Converted value with appropriate type (bool, float, int, or str).
"""
if not value or value.strip() == '':
return ''
value_lower = value.lower().strip()
# Check for boolean
if value_lower in ('true', 'false'):
return value_lower == 'true'
# Check for numeric values
try:
# Check for float (has decimal point or scientific notation)
if '.' in value or 'e' in value_lower:
return float(value)
# Try int
else:
return int(value)
except ValueError:
pass
# Default to string
return value
class MyTestClient:
"""
A test client helper for FastHTML applications that provides
a more user-friendly API for testing HTML responses.
This class wraps Starlette's TestClient and provides methods
to verify page content in a way similar to NiceGui's test fixtures.
"""
def __init__(self, app: FastHTML, parent_levels: int = 1):
"""
Initialize the test client.
Args:
app: The FastHTML application to test.
parent_levels: Number of parent levels to show in error messages (default: 1).
"""
self.app = app
self.client = TestClient(app)
self._content = None
self._soup = None
self._session = str(uuid.uuid4())
self.parent_levels = parent_levels
# make sure that the commands are mounted
mount_commands(self.app)
def open(self, path: str) -> Self:
"""
Open a page and store its content for subsequent assertions.
Args:
path: The URL path to request (e.g., '/home', '/api/users').
Returns:
self: Returns the client instance for method chaining.
Raises:
AssertionError: If the response status code is not 200.
"""
res = self.client.get(path)
assert res.status_code == 200, (
f"Failed to open '{path}'. "
f"status code={res.status_code} : reason='{res.text}'"
)
self.set_content(res.text)
return self
def send_request(self, method: str, url: str, headers: dict = None, data=None, json_data=None):
if json_data is not None:
json_data['session'] = self._session
res = self.client.request(
method,
url,
headers=headers,
data=data, # For form data
json=json_data # For JSON bodies (e.g., from hx_vals)
)
assert res.status_code == 200, (
f"Failed to send request '{method=}', {url=}. "
f"status code={res.status_code} : reason='{res.text}'"
)
self.set_content(res.text)
return self
def should_see(self, text: str) -> Self:
"""
Assert that the given text is present in the visible page content.
This method parses the HTML and searches only in the visible text,
ignoring HTML tags and attributes.
Args:
text: The text string to search for (case-sensitive).
Returns:
self: Returns the client instance for method chaining.
Raises:
AssertionError: If the text is not found in the page content.
ValueError: If no page has been opened yet.
"""
def clean_text(txt):
return "\n".join(line for line in txt.splitlines() if line.strip())
if self._content is None:
raise ValueError(
"No page content available. Call open() before should_see()."
)
visible_text = self._soup.get_text()
if text not in visible_text:
# Provide a snippet of the actual content for debugging
snippet_length = 200
content_snippet = clean_text(
visible_text[:snippet_length] + "..."
if len(visible_text) > snippet_length
else visible_text
)
raise AssertionError(
f"Expected to see '{text}' in page content but it was not found.\n"
f"Visible content (first {snippet_length} chars): {content_snippet}"
)
return self
def should_not_see(self, text: str) -> Self:
"""
Assert that the given text is NOT present in the visible page content.
This method parses the HTML and searches only in the visible text,
ignoring HTML tags and attributes.
Args:
text: The text string that should not be present (case-sensitive).
Returns:
self: Returns the client instance for method chaining.
Raises:
AssertionError: If the text is found in the page content.
ValueError: If no page has been opened yet.
"""
if self._content is None:
raise ValueError(
"No page content available. Call open() before should_not_see()."
)
visible_text = self._soup.get_text()
if text in visible_text:
element = self._find_visible_text_element(self._soup, text)
if element:
context = self._format_element_with_context(element, self.parent_levels)
error_msg = (
f"Expected NOT to see '{text}' in page content but it was found.\n"
f"Found in:\n{context}"
)
else:
error_msg = (
f"Expected NOT to see '{text}' in page content but it was found.\n"
f"Unable to locate the element containing this text."
)
raise AssertionError(error_msg)
return self
def find_element(self, selector: str) -> TestableElement:
"""
Find a single HTML element using a CSS selector.
This method searches for elements matching the given CSS selector.
It expects to find exactly one matching element.
Args:
selector: A CSS selector string (e.g., '#my-id', '.my-class', 'button.primary').
Returns:
TestableElement: A testable element wrapping the HTML fragment.
Raises:
ValueError: If no page has been opened yet.
AssertionError: If no element or multiple elements match the selector.
Examples:
element = client.open('/').find_element('#login-button')
element = client.find_element('button.primary')
"""
if self._content is None:
raise ValueError(
"No page content available. Call open() before find_element()."
)
results = self._soup.select(selector)
if len(results) == 0:
raise AssertionError(
f"No element found matching selector '{selector}'."
)
elif len(results) == 1:
return TestableElement(self, results[0])
else:
raise AssertionError(
f"Found {len(results)} elements matching selector '{selector}'. Expected exactly 1."
)
def find_form(self, fields: list = None) -> TestableForm:
"""
Find a form element in the page content.
Can provide title of the fields to ease the search
:param fields:
:return:
"""
if self._content is None:
raise ValueError(
"No page content available. Call open() before find_form()."
)
results = self._soup.select("form")
if len(results) == 0:
raise AssertionError(
f"No form found."
)
if fields is None:
remaining = [TestableForm(self, form) for form in results]
else:
remaining = []
for form in results:
testable_form = TestableForm(self, form)
if all(testable_form.translate(field) in testable_form.fields for field in fields):
remaining.append(testable_form)
if len(remaining) == 1:
return remaining[0]
else:
raise AssertionError(
f"Found {len(remaining)} forms (with the specified fields). Expected exactly 1."
)
def get_content(self) -> str:
"""
Get the raw HTML content of the last opened page.
Returns:
The HTML content as a string, or None if no page has been opened.
"""
return self._content
def set_content(self, content: str) -> Self:
"""
Set the HTML content and parse it with BeautifulSoup.
Args:
content: The HTML content string to set.
"""
self._content = content
self._soup = BeautifulSoup(content, 'html.parser')
return self
@staticmethod
def _find_visible_text_element(soup, text: str):
"""
Find the first element containing the visible text.
This method traverses the BeautifulSoup tree to find the first element
whose visible text content (including descendants) contains the search text.
Args:
soup: BeautifulSoup object representing the parsed HTML.
text: The text to search for.
Returns:
BeautifulSoup element containing the text, or None if not found.
"""
# Traverse all elements in the document
for element in soup.descendants:
# Skip NavigableString nodes, we want Tag elements
if not isinstance(element, Tag):
continue
# Get visible text of this element and its descendants
element_text = element.get_text()
# Check if our search text is in this element's visible text
if text in element_text:
# Found it! But we want the smallest element containing the text
# So let's check if any of its children also contain the text
found_in_child = False
for child in element.children:
if isinstance(child, Tag) and text in child.get_text():
found_in_child = True
break
# If no child contains the text, this is our target element
if not found_in_child:
return element
return None
@staticmethod
def _indent_html(html_str: str, indent: int = 2):
"""
Add indentation to HTML string.
Args:
html_str: HTML string to indent.
indent: Number of spaces for indentation.
Returns:
str: Indented HTML string.
"""
lines = html_str.split('\n')
indented_lines = [' ' * indent + line for line in lines if line.strip()]
return '\n'.join(indented_lines)
def _format_element_with_context(self, element, parent_levels: int):
"""
Format an element with its parent context for display.
Args:
element: BeautifulSoup element to format.
parent_levels: Number of parent levels to include.
Returns:
str: Formatted HTML string with indentation.
"""
# Collect the element and its parents
elements_to_show = [element]
current = element
for _ in range(parent_levels):
if current.parent and current.parent.name: # Skip NavigableString parents
elements_to_show.insert(0, current.parent)
current = current.parent
else:
break
# Format the top-level element with proper indentation
if len(elements_to_show) == 1:
return self._indent_html(str(element), indent=2)
# Build the nested structure
result = self._build_nested_context(elements_to_show, element)
return self._indent_html(result, indent=2)
def _build_nested_context(self, elements_chain, target_element):
"""
Build nested HTML context showing parents and target element.
Args:
elements_chain: List of elements from outermost parent to target.
target_element: The element that contains the searched text.
Returns:
str: Nested HTML structure.
"""
if len(elements_chain) == 1:
return str(target_element)
# Get the outermost element
outer = elements_chain[0]
# Start with opening tag
result = f"<{outer.name}"
if outer.attrs:
attrs = ' '.join(f'{k}="{v}"' if not isinstance(v, list) else f'{k}="{" ".join(v)}"'
for k, v in outer.attrs.items())
result += f" {attrs}"
result += ">\n"
# Add nested content
if len(elements_chain) == 2:
# This is the target element
result += self._indent_html(str(target_element), indent=2) + "\n"
else:
# Recursive call for deeper nesting
nested = self._build_nested_context(elements_chain[1:], target_element)
result += self._indent_html(nested, indent=2) + "\n"
# Closing tag
result += f"</{outer.name}>"
return result

View File

@@ -1,5 +1,16 @@
import logging
from bs4 import Tag
from fastcore.xml import FT
from fasthtml.fastapp import fast_app
from starlette.routing import Mount
from myfasthtml.core.constants import Routes, ROUTE_ROOT
from myfasthtml.test.MyFT import MyFT
utils_app, utils_rt = fast_app()
logger = logging.getLogger("Commands")
def mount_if_not_exists(app, path: str, sub_app):
"""
@@ -17,3 +28,166 @@ def mount_if_not_exists(app, path: str, sub_app):
if not is_mounted:
app.mount(path, app=sub_app)
def merge_classes(*args):
all_elements = []
for element in args:
if element is None or element == '':
continue
if isinstance(element, (tuple, list, set)):
all_elements.extend(element)
elif isinstance(element, dict):
if "cls" in element:
all_elements.append(element.pop("cls"))
elif "class" in element:
all_elements.append(element.pop("class"))
elif isinstance(element, str):
all_elements.append(element)
else:
raise ValueError(f"Cannot merge {element} of type {type(element)}")
if all_elements:
# Remove duplicates while preserving order
unique_elements = list(dict.fromkeys(all_elements))
return " ".join(unique_elements)
else:
return None
def debug_routes(app):
def _debug_routes(_app, _route, prefix=""):
if isinstance(_route, Mount):
for sub_route in _route.app.router.routes:
_debug_routes(_app, sub_route, prefix=_route.path)
else:
print(f"path={prefix}{_route.path}, methods={_route.methods}, endpoint={_route.endpoint}")
for route in app.router.routes:
_debug_routes(app, route)
def mount_utils(app):
"""
Mounts the commands_app to the given application instance if the route does not already exist.
:param app: The application instance to which the commands_app will be mounted.
:type app: Any
:return: Returns the result of the mount operation performed by mount_if_not_exists.
:rtype: Any
"""
return mount_if_not_exists(app, ROUTE_ROOT, utils_app)
def get_default_ft_attr(ft):
"""
for every type of HTML element (ft) gives the default attribute to use for binding
:param ft:
:return:
"""
if ft.tag == "input":
if ft.attrs.get("type") == "checkbox":
return "checked"
elif ft.attrs.get("type") == "radio":
return "checked"
elif ft.attrs.get("type") == "file":
return "files"
else:
return "value"
else:
return None # indicate that the content of the FT should be updated
def get_default_attr(data):
all_attrs = data.__dict__.keys()
return next(iter(all_attrs))
def is_checkbox(elt):
if isinstance(elt, (FT, MyFT)):
return elt.tag == "input" and elt.attrs.get("type", None) == "checkbox"
elif isinstance(elt, Tag):
return elt.name == "input" and elt.attrs.get("type", None) == "checkbox"
else:
return False
def is_radio(elt):
if isinstance(elt, (FT, MyFT)):
return elt.tag == "input" and elt.attrs.get("type", None) == "radio"
elif isinstance(elt, Tag):
return elt.name == "input" and elt.attrs.get("type", None) == "radio"
else:
return False
def is_select(elt):
if isinstance(elt, (FT, MyFT)):
return elt.tag == "select"
elif isinstance(elt, Tag):
return elt.name == "select"
else:
return False
def is_datalist(elt):
if isinstance(elt, (FT, MyFT)):
return elt.tag == "datalist"
elif isinstance(elt, Tag):
return elt.name == "datalist"
else:
return False
def quoted_str(s):
if s is None:
return "None"
if isinstance(s, str):
if "'" in s and '"' in s:
return f'"{s.replace('"', '\\"')}"'
elif '"' in s:
return f"'{s}'"
else:
return f'"{s}"'
return str(s)
@utils_rt(Routes.Commands)
def post(session, c_id: str):
"""
Default routes for all commands.
:param session:
:param c_id:
:return:
"""
logger.debug(f"Entering {Routes.Commands} with {session=}, {c_id=}")
from myfasthtml.core.commands import CommandsManager
command = CommandsManager.get_command(c_id)
if command:
return command.execute()
raise ValueError(f"Command with ID '{c_id}' not found.")
@utils_rt(Routes.Bindings)
def post(session, b_id: str, values: dict):
"""
Default routes for all bindings.
:param session:
:param b_id:
:param values:
:return:
"""
logger.debug(f"Entering {Routes.Bindings} with {session=}, {b_id=}, {values=}")
from myfasthtml.core.bindings import BindingsManager
binding = BindingsManager.get_binding(b_id)
if binding:
return binding.update(values)
raise ValueError(f"Binding with ID '{b_id}' not found.")

View File

View File

@@ -0,0 +1,51 @@
import logging
from dataclasses import dataclass
from fasthtml import serve
from fasthtml.components import *
from myfasthtml.controls.helpers import mk
from myfasthtml.core.bindings import Binding, BooleanConverter
from myfasthtml.core.utils import debug_routes
from myfasthtml.myfastapp import create_app
logging.basicConfig(
level=logging.DEBUG, # Set logging level to DEBUG
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', # Log format
datefmt='%Y-%m-%d %H:%M:%S', # Timestamp format
)
app, rt = create_app(protect_routes=False)
@dataclass
class Data:
value: str = "Hello World"
checked: bool = False
data = Data()
@rt("/set_checkbox")
def post(check_box_name: str = None):
print(check_box_name)
@rt("/")
def index():
return Div(
mk.mk(Input(name="checked_name", type="checkbox"), binding=Binding(data, attr="checked")),
mk.mk(Label("Text"), binding=Binding(data, attr="checked", converter=BooleanConverter())),
)
@rt("/test_checkbox_htmx")
def get():
check_box = Input(type="checkbox", name="check_box_name", hx_post="/set_checkbox")
return check_box
if __name__ == "__main__":
debug_routes(app)
serve(port=5002)

View File

@@ -0,0 +1,66 @@
import logging
from dataclasses import dataclass
from typing import Any
from fasthtml import serve
from fasthtml.components import *
from myfasthtml.controls.helpers import mk
from myfasthtml.core.bindings import Binding
from myfasthtml.core.commands import Command
from myfasthtml.core.utils import debug_routes
from myfasthtml.myfastapp import create_app
logging.basicConfig(
level=logging.DEBUG, # Set logging level to DEBUG
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', # Log format
datefmt='%Y-%m-%d %H:%M:%S', # Timestamp format
)
app, rt = create_app(protect_routes=False)
@dataclass
class Data:
value: Any = "Hello World"
def add_suggestion():
nb = len(data.value)
data.value = data.value + [f"suggestion{nb}"]
def remove_suggestion():
if len(data.value) > 0:
data.value = data.value[:-1]
data = Data(["suggestion0", "suggestion1", "suggestion2"])
@rt("/")
def get():
datalist = Datalist(
id="suggestions"
)
input_elt = Input(name="input_name", list="suggestions")
label_elt = Label()
mk.manage_binding(datalist, Binding(data))
mk.manage_binding(label_elt, Binding(data))
add_button = mk.button("Add", command=Command("Add", "Add a suggestion", add_suggestion).bind(data))
remove_button = mk.button("Remove", command=Command("Remove", "Remove a suggestion", remove_suggestion).bind(data))
return Div(
add_button,
remove_button,
input_elt,
datalist,
label_elt
)
if __name__ == "__main__":
debug_routes(app)
serve(port=5002)

View File

@@ -0,0 +1,33 @@
from dataclasses import dataclass
from typing import Any
from fasthtml import serve
from fasthtml.components import *
from myfasthtml.controls.helpers import mk
from myfasthtml.core.bindings import Binding
from myfasthtml.core.utils import debug_routes
from myfasthtml.myfastapp import create_app
app, rt = create_app(protect_routes=False)
@dataclass
class Data:
value: Any = "Hello World"
data = Data()
@rt("/")
def get():
return Div(
mk.mk(Input(name="input_name"), binding=Binding(data, attr="value").htmx(trigger="input changed")),
mk.mk(Label("Text"), binding=Binding(data, attr="value"))
)
if __name__ == "__main__":
debug_routes(app)
serve(port=5002)

View File

@@ -0,0 +1,47 @@
import logging
from dataclasses import dataclass
from fasthtml import serve
from fasthtml.components import *
from myfasthtml.controls.helpers import mk
from myfasthtml.core.bindings import Binding
from myfasthtml.core.utils import debug_routes
from myfasthtml.myfastapp import create_app
logging.basicConfig(
level=logging.DEBUG, # Set logging level to DEBUG
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', # Log format
datefmt='%Y-%m-%d %H:%M:%S', # Timestamp format
)
app, rt = create_app(protect_routes=False)
@dataclass
class Data:
value: str = "Hello World"
checked: bool = False
data = Data()
@rt("/")
def get():
radio1 = Input(type="radio", name="radio_name", value="option1")
radio2 = Input(type="radio", name="radio_name", value="option2", checked=True)
radio3 = Input(type="radio", name="radio_name", value="option3")
label_elt = Label("hi hi hi !")
mk.manage_binding(radio1, Binding(data))
mk.manage_binding(radio2, Binding(data))
mk.manage_binding(radio3, Binding(data))
mk.manage_binding(label_elt, Binding(data))
return radio1, radio2, radio3, label_elt
if __name__ == "__main__":
debug_routes(app)
serve(port=5002)

View File

@@ -0,0 +1,40 @@
from dataclasses import dataclass
from typing import Any
from fasthtml import serve
from fasthtml.components import *
from myfasthtml.controls.helpers import mk
from myfasthtml.core.bindings import Binding
from myfasthtml.core.utils import debug_routes
from myfasthtml.myfastapp import create_app
app, rt = create_app(protect_routes=False)
@dataclass
class Data:
value: Any = "Hello World"
data = Data(50)
@rt("/")
def get():
range_elt = Input(
type="range",
name="range_name",
min="0",
max="100",
value="50"
)
label_elt = Label()
mk.manage_binding(range_elt, Binding(data))
mk.manage_binding(label_elt, Binding(data))
return range_elt, label_elt
if __name__ == "__main__":
debug_routes(app)
serve(port=5002)

View File

@@ -0,0 +1,46 @@
import logging
from dataclasses import dataclass
from typing import Any
from fasthtml import serve
from fasthtml.components import *
from myfasthtml.controls.helpers import mk
from myfasthtml.core.bindings import Binding
from myfasthtml.core.utils import debug_routes
from myfasthtml.myfastapp import create_app
logging.basicConfig(
level=logging.DEBUG, # Set logging level to DEBUG
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', # Log format
datefmt='%Y-%m-%d %H:%M:%S', # Timestamp format
)
app, rt = create_app(protect_routes=False)
@dataclass
class Data:
value: Any = "Hello World"
data = Data()
@rt("/")
def get():
select_elt = Select(
Option("Option 1", value="option1"),
Option("Option 2", value="option2"),
Option("Option 3", value="option3"),
name="select_name"
)
label_elt = Label()
mk.manage_binding(select_elt, Binding(data), init_binding=False)
mk.manage_binding(label_elt, Binding(data))
return select_elt, label_elt
if __name__ == "__main__":
debug_routes(app)
serve(port=5002)

View File

@@ -0,0 +1,47 @@
import logging
from dataclasses import dataclass
from typing import Any
from fasthtml import serve
from fasthtml.components import *
from myfasthtml.controls.helpers import mk
from myfasthtml.core.bindings import Binding
from myfasthtml.core.utils import debug_routes
from myfasthtml.myfastapp import create_app
logging.basicConfig(
level=logging.DEBUG, # Set logging level to DEBUG
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', # Log format
datefmt='%Y-%m-%d %H:%M:%S', # Timestamp format
)
app, rt = create_app(protect_routes=False)
@dataclass
class Data:
value: Any = "Hello World"
data = Data()
@rt("/")
def get():
select_elt = Select(
Option("Option 1", value="option1"),
Option("Option 2", value="option2"),
Option("Option 3", value="option3"),
name="select_name",
multiple=True
)
label_elt = Label()
mk.manage_binding(select_elt, Binding(data), init_binding=False)
mk.manage_binding(label_elt, Binding(data))
return select_elt, label_elt
if __name__ == "__main__":
debug_routes(app)
serve(port=5002)

View File

@@ -0,0 +1,33 @@
from dataclasses import dataclass
from typing import Any
from fasthtml import serve
from fasthtml.components import *
from myfasthtml.controls.helpers import mk
from myfasthtml.core.bindings import Binding
from myfasthtml.core.utils import debug_routes
from myfasthtml.myfastapp import create_app
app, rt = create_app(protect_routes=False)
@dataclass
class Data:
value: Any = "Hello World"
data = Data()
@rt("/")
def get():
return Div(
mk.mk(Textarea(name="input_name"), binding=Binding(data, attr="value").htmx(trigger="input changed")),
mk.mk(Label("Text"), binding=Binding(data, attr="value"))
)
if __name__ == "__main__":
debug_routes(app)
serve(port=5002)

View File

@@ -0,0 +1,26 @@
from fasthtml import serve
from myfasthtml.controls.helpers import mk
from myfasthtml.core.commands import Command
from myfasthtml.myfastapp import create_app
# Define a simple command action
def say_hello():
return "Hello, FastHtml!"
# Create the command
hello_command = Command("say_hello", "Responds with a greeting", say_hello)
# Create the app
app, rt = create_app(protect_routes=False)
@rt("/")
def get_homepage():
return mk.button("Click Me!", command=hello_command)
if __name__ == "__main__":
serve(port=5002)

View File

@@ -0,0 +1,25 @@
from fasthtml import serve
from fasthtml.components import *
from myfasthtml.controls.helpers import mk
from myfasthtml.core.commands import Command
from myfasthtml.icons.fa import icon_home
from myfasthtml.myfastapp import create_app
app, rt = create_app(protect_routes=False)
def change_text():
return "New text"
command = Command("change_text", "change the text", change_text).htmx(target="#text")
@rt("/")
def index():
return mk.button(Div(mk.icon(icon_home), Div("Hello World", id="text"), cls="flex"), command=command)
if __name__ == "__main__":
serve(port=5002)

View File

@@ -0,0 +1,15 @@
from fasthtml import serve
from fasthtml.components import *
from myfasthtml.myfastapp import create_app
app, rt = create_app(protect_routes=False)
@rt("/")
def get_homepage():
return Div("Hello, FastHtml!")
if __name__ == "__main__":
serve(port=5002)

View File

@@ -1,3 +1,4 @@
import logging
from importlib.resources import files
from pathlib import Path
from typing import Optional, Any
@@ -8,6 +9,9 @@ from starlette.responses import Response
from myfasthtml.auth.routes import setup_auth_routes
from myfasthtml.auth.utils import create_auth_beforeware
from myfasthtml.core.utils import utils_app
logger = logging.getLogger("MyFastHtml")
def get_asset_path(filename):
@@ -25,7 +29,7 @@ def get_asset_content(filename):
return get_asset_path(filename).read_text()
def create_app(daisyui: Optional[bool] = False,
def create_app(daisyui: Optional[bool] = True,
protect_routes: Optional[bool] = True,
mount_auth_app: Optional[bool] = False,
**kwargs) -> Any:
@@ -50,10 +54,10 @@ def create_app(daisyui: Optional[bool] = False,
:return: A tuple containing the FastHtml application instance and the associated router.
:rtype: Any
"""
hdrs = []
hdrs = [Link(href="/myfasthtml/myfasthtml.css", rel="stylesheet", type="text/css")]
if daisyui:
hdrs = [
hdrs += [
Link(href="/myfasthtml/daisyui-5.css", rel="stylesheet", type="text/css"),
Link(href="/myfasthtml/daisyui-5-themes.css", rel="stylesheet", type="text/css"),
Script(src="/myfasthtml/tailwindcss-browser@4.js"),
@@ -84,6 +88,9 @@ def create_app(daisyui: Optional[bool] = False,
# and put it back after the myfasthtml static files routes
app.routes.append(static_route_exts_get)
# route the commands and the bindings
app.mount("/myfasthtml", utils_app)
if mount_auth_app:
# Setup authentication routes
setup_auth_routes(app, rt)

View File

@@ -0,0 +1,9 @@
from dataclasses import dataclass, field
@dataclass
class MyFT:
tag: str
attrs: dict
children: list['MyFT'] = field(default_factory=list)
text: str | None = None

View File

View File

@@ -3,7 +3,8 @@ from dataclasses import dataclass
from fastcore.basics import NotStr
from myfasthtml.core.testclient import MyFT
from myfasthtml.core.utils import quoted_str
from myfasthtml.test.testclient import MyFT
class Predicate:
@@ -14,10 +15,13 @@ class Predicate:
raise NotImplementedError
def __str__(self):
return f"{self.__class__.__name__}({self.value})"
return f"{self.__class__.__name__}({self.value if self.value is not None else ''})"
def __repr__(self):
return f"{self.__class__.__name__}({self.value if self.value is not None else ''})"
def __eq__(self, other):
if not isinstance(other, Predicate):
if type(self) is not type(other):
return False
return self.value == other.value
@@ -25,7 +29,15 @@ class Predicate:
return hash(self.value)
class StartsWith(Predicate):
class AttrPredicate(Predicate):
"""
Predicate that validates an attribute value.
It's given as a value of an attribute.
"""
pass
class StartsWith(AttrPredicate):
def __init__(self, value):
super().__init__(value)
@@ -33,7 +45,7 @@ class StartsWith(Predicate):
return actual.startswith(self.value)
class Contains(Predicate):
class Contains(AttrPredicate):
def __init__(self, value):
super().__init__(value)
@@ -41,7 +53,7 @@ class Contains(Predicate):
return self.value in actual
class DoesNotContain(Predicate):
class DoesNotContain(AttrPredicate):
def __init__(self, value):
super().__init__(value)
@@ -49,16 +61,56 @@ class DoesNotContain(Predicate):
return self.value not in actual
class AnyValue(AttrPredicate):
"""
True is the attribute is present and the value is not None.
"""
def __init__(self):
super().__init__(None)
def validate(self, actual):
return actual is not None
class ChildrenPredicate(Predicate):
"""
Predicate given as a child of an element.
"""
def to_debug(self, element):
return element
class Empty(ChildrenPredicate):
def __init__(self):
super().__init__(None)
def validate(self, actual):
return len(actual.children) == 0 and len(actual.attrs) == 0
class AttributeForbidden(ChildrenPredicate):
"""
To validate that an attribute is not present in an element.
"""
def __init__(self, value):
super().__init__(value)
def validate(self, actual):
return self.value not in actual.attrs or actual.attrs[self.value] is None
def to_debug(self, element):
element.attrs[self.value] = "** NOT ALLOWED **"
return element
@dataclass
class DoNotCheck:
desc: str = None
@dataclass
class Empty:
desc: str = None
class ErrorOutput:
def __init__(self, path, element, expected):
self.path = path
@@ -77,7 +129,7 @@ class ErrorOutput:
return item, None, None
def __str__(self):
self.compute()
return f"ErrorOutput({self.output})"
def compute(self):
# first render the path hierarchy
@@ -99,30 +151,31 @@ class ErrorOutput:
self._add_to_output(error_str)
# render the children
if len(self.expected.children) > 0:
expected_children = [c for c in self.expected.children if not isinstance(c, ChildrenPredicate)]
if len(expected_children) > 0:
self.indent += " "
element_index = 0
for expected_child in self.expected.children:
if hasattr(expected_child, "tag"):
if element_index < len(self.element.children):
# display the child
element_child = self.element.children[element_index]
child_str = self._str_element(element_child, expected_child, keep_open=False)
self._add_to_output(child_str)
# manage errors in children
child_error_str = self._detect_error(element_child, expected_child)
if child_error_str:
self._add_to_output(child_error_str)
element_index += 1
else:
# When there are fewer children than expected, we display a placeholder
child_str = "! ** MISSING ** !"
self._add_to_output(child_str)
for expected_child in expected_children:
if element_index >= len(self.element.children):
# When there are fewer children than expected, we display a placeholder
child_str = "! ** MISSING ** !"
self._add_to_output(child_str)
element_index += 1
continue
else:
self._add_to_output(expected_child)
# display the child
element_child = self.element.children[element_index]
child_str = self._str_element(element_child, expected_child, keep_open=False)
self._add_to_output(child_str)
# manage errors (only when the expected is a FT element
if hasattr(expected_child, "tag"):
child_error_str = self._detect_error(element_child, expected_child)
if child_error_str:
self._add_to_output(child_error_str)
# continue
element_index += 1
self.indent = self.indent[:-2]
self._add_to_output(")")
@@ -142,24 +195,27 @@ class ErrorOutput:
if expected is None:
expected = element
# the attributes are compared to the expected element
elt_attrs = {attr_name: element.attrs.get(attr_name, "** MISSING **") for attr_name in
[attr_name for attr_name in expected.attrs if attr_name is not None]}
elt_attrs_str = " ".join(f'"{attr_name}"="{attr_value}"' for attr_name, attr_value in elt_attrs.items())
if hasattr(element, "tag"):
# the attributes are compared to the expected element
elt_attrs = {attr_name: element.attrs.get(attr_name, "** MISSING **") for attr_name in
[attr_name for attr_name in expected.attrs if attr_name is not None]}
elt_attrs_str = " ".join(f'"{attr_name}"="{attr_value}"' for attr_name, attr_value in elt_attrs.items())
tag_str = f"({element.tag} {elt_attrs_str}"
# manage the closing tag
if keep_open is False:
tag_str += " ...)" if len(element.children) > 0 else ")"
elif keep_open is True:
tag_str += "..." if elt_attrs_str == "" else " ..."
else:
# close the tag if there are no children
not_special_children = [c for c in element.children if not isinstance(c, Predicate)]
if len(not_special_children) == 0: tag_str += ")"
return tag_str
#
tag_str = f"({element.tag} {elt_attrs_str}"
# manage the closing tag
if keep_open is False:
tag_str += " ...)" if len(element.children) > 0 else ")"
elif keep_open is True:
tag_str += "..." if elt_attrs_str == "" else " ..."
else:
# close the tag if there are no children
if len(element.children) == 0: tag_str += ")"
return tag_str
return quoted_str(element)
def _detect_error(self, element, expected):
if hasattr(expected, "tag") and hasattr(element, "tag"):
@@ -307,16 +363,18 @@ def matches(actual, expected, path=""):
_actual=actual.tag,
_expected=expected.tag)
# special case when the expected element is empty
if len(expected.children) > 0 and isinstance(expected.children[0], Empty):
assert len(actual.children) == 0, _error_msg("Actual is not empty:", _actual=actual)
assert len(actual.attrs) == 0, _error_msg("Actual is not empty:", _actual=actual)
return True
# 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.attrs)
_actual=actual,
_expected=expected)
if isinstance(expected_value, Predicate):
assert expected_value.validate(actual.attrs[expected_attr]), \
@@ -327,14 +385,15 @@ def matches(actual, expected, path=""):
else:
assert actual.attrs[expected_attr] == expected.attrs[expected_attr], \
_error_msg(f"The values are different for '{expected_attr}': ",
_actual=actual.attrs[expected_attr],
_expected=expected.attrs[expected_attr])
_actual=actual,
_expected=expected)
# compare the children
if len(actual.children) < len(expected.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):
for actual_child, expected_child in zip(actual.children, expected_children):
assert matches(actual_child, expected_child, path=path)
else:

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,7 @@ from fasthtml.fastapp import fast_app
import myfasthtml.auth.utils
from myfasthtml.auth.routes import setup_auth_routes
from myfasthtml.auth.utils import create_auth_beforeware, register_user
from myfasthtml.core.testclient import MyTestClient
from myfasthtml.test.testclient import MyTestClient
@dataclass

View File

@@ -2,7 +2,9 @@ import pytest
from fasthtml.fastapp import fast_app
from myfasthtml.auth.utils import create_auth_beforeware
from myfasthtml.core.testclient import MyTestClient
from myfasthtml.core.utils import quoted_str
from myfasthtml.test.testclient import MyTestClient
def test_non_protected_route():
app, rt = fast_app()
@@ -31,3 +33,15 @@ def test_all_routes_are_protected():
user.open("/")
user.should_see("Sign In")
@pytest.mark.parametrize("actual,expected", [
("string", '"string"'),
("string with 'single quotes'", '''"string with 'single quotes'"'''),
('string with "double quotes"', """'string with "double quotes"'"""),
("""string with 'single' and "double" quotes""", '''"string with 'single' and \\"double\\" quotes"'''),
(None, "None"),
(123, "123"),
])
def test_i_can_quote_str(actual, expected):
assert quoted_str(actual) == expected

View File

View File

@@ -0,0 +1,91 @@
from dataclasses import dataclass
from typing import Any
import pytest
from fasthtml.components import *
from fasthtml.fastapp import fast_app
from myfasthtml.controls.helpers import mk
from myfasthtml.core.bindings import Binding
from myfasthtml.core.commands import Command
from myfasthtml.test.matcher import matches
from myfasthtml.test.testclient import MyTestClient
@dataclass
class Data:
value: Any
@pytest.fixture()
def user():
test_app, rt = fast_app(default_hdrs=False)
user = MyTestClient(test_app)
return user
@pytest.fixture()
def rt(user):
return user.app.route
def test_i_can_mk_button():
button = mk.button('button')
expected = Button('button')
assert matches(button, expected)
def test_i_can_mk_button_with_attrs():
button = mk.button('button', id="button_id", class_="button_class")
expected = Button('button', id="button_id", class_="button_class")
assert matches(button, expected)
def test_i_can_mk_button_with_command(user, rt):
def new_value(value): return value
command = Command('test', 'TestingCommand', new_value, "this is my new value")
@rt('/')
def get(): return mk.button('button', command)
user.open("/")
user.should_see("button")
user.find_element("button").click()
user.should_see("this is my new value")
class TestingBindings:
@pytest.fixture()
def data(self):
return Data("value")
def test_i_can_bind_an_input(self, data):
elt = Input(name="input_elt", value="hello")
binding = Binding(data, "value")
elt = mk.manage_binding(elt, binding)
# element is updated
assert "hx-post" in elt.attrs
assert "hx-vals" in elt.attrs
assert "b_id" in elt.attrs["hx-vals"]
# binding is also updated
assert binding.ft == elt
assert binding.ft_name == "input_elt"
def test_i_can_bind_none_input(self, data):
elt = Label("hello", name="input_elt")
binding = Binding(data, "value")
elt = mk.manage_binding(elt, binding)
# element is updated
assert "hx-post" not in elt.attrs
assert "hx-get" not in elt.attrs
# binding is also updated
assert binding.ft == elt
assert binding.ft_name == "input_elt"

View File

@@ -0,0 +1,891 @@
"""
Comprehensive binding tests for all bindable FastHTML components.
This test suite covers:
- Input (text) - already tested
- Checkbox - already tested
- Textarea
- Select (single)
- Select (multiple)
- Range (slider)
- Radio buttons
- Button
- Input with Datalist (combobox)
"""
from dataclasses import dataclass
from typing import Any
import pytest
from fasthtml.components import (
Input, Label, Textarea, Select, Option, Datalist
)
from fasthtml.fastapp import fast_app
from myfasthtml.controls.helpers import mk
from myfasthtml.core.bindings import Binding, BooleanConverter
from myfasthtml.core.constants import Routes, ROUTE_ROOT
from myfasthtml.test.matcher import matches, AttributeForbidden, AnyValue
from myfasthtml.test.testclient import MyTestClient
@dataclass
class Data:
value: Any = "hello world"
@dataclass
class NumericData:
value: int = 50
@dataclass
class BoolData:
value: bool = True
@dataclass
class ListData:
value: list = None
def __post_init__(self):
if self.value is None:
self.value = []
@pytest.fixture()
def user():
test_app, rt = fast_app(default_hdrs=False)
user = MyTestClient(test_app)
return user
@pytest.fixture()
def rt(user):
return user.app.route
class TestBindingTextarea:
"""Tests for binding Textarea components."""
def test_i_can_bind_textarea(self):
data = Data("")
check_box = Textarea(name="textarea_name")
binding = Binding(data)
mk.manage_binding(check_box, binding)
# update the content
res = binding.update({"textarea_name": "Hello world !"})
expected = [Textarea("Hello world !", name="textarea_name", hx_swap_oob="true")]
assert matches(res, expected)
def test_i_can_bind_textarea_with_label(self, user, rt):
"""
Textarea should bind bidirectionally with data.
Value changes should update the label.
"""
@rt("/")
def index():
data = Data("Initial text")
textarea_elt = Textarea(name="textarea_name")
label_elt = Label()
mk.manage_binding(textarea_elt, Binding(data))
mk.manage_binding(label_elt, Binding(data))
return textarea_elt, label_elt
user.open("/")
user.should_see("Initial text")
testable_textarea = user.find_element("textarea")
testable_textarea.send("New multiline\ntext content")
user.should_see("New multiline\ntext content")
def test_textarea_append_works_with_binding(self, user, rt):
"""
Appending text to textarea should trigger binding update.
"""
@rt("/")
def index():
data = Data("Start")
textarea_elt = Textarea(name="textarea_name")
label_elt = Label()
mk.manage_binding(textarea_elt, Binding(data))
mk.manage_binding(label_elt, Binding(data))
return textarea_elt, label_elt
user.open("/")
user.should_see("Start")
testable_textarea = user.find_element("textarea")
testable_textarea.append(" + More")
user.should_see("Start + More")
def test_textarea_clear_works_with_binding(self, user, rt):
"""
Clearing textarea should update binding to empty string.
"""
@rt("/")
def index():
data = Data("Content to clear")
textarea_elt = Textarea(name="textarea_name")
label_elt = Label()
mk.manage_binding(textarea_elt, Binding(data))
mk.manage_binding(label_elt, Binding(data))
return textarea_elt, label_elt
user.open("/")
user.should_see("Content to clear")
testable_textarea = user.find_element("textarea")
testable_textarea.clear()
user.should_not_see("Content to clear")
class TestBindingSelect:
"""Tests for binding Select components (single selection)."""
def test_i_can_bind_select(self):
data = Data("")
select_elt = Select(
Option("Option 1", value="option1"),
Option("Option 2", value="option2"),
Option("Option 3", value="option3"),
name="select_name"
)
binding = Binding(data)
updated = mk.manage_binding(select_elt, binding)
expected = Select(
AttributeForbidden("hx_swap_oob"),
Option("Option 1", value="option1"),
Option("Option 2", value="option2"),
Option("Option 3", value="option3"),
name="select_name",
id=AnyValue(),
hx_post=f"{ROUTE_ROOT}{Routes.Bindings}",
)
assert matches(updated, expected)
def test_i_can_update_select(self):
data = Data("")
select_elt = Select(
Option("Option 1", value="option1"),
Option("Option 2", value="option2"),
Option("Option 3", value="option3"),
name="select_name"
)
binding = Binding(data)
mk.manage_binding(select_elt, binding)
res = binding.update({"select_name": "option2"})
expected = Select(
Option("Option 1", value="option1"),
Option("Option 2", value="option2", selected="true"),
Option("Option 3", value="option3"),
name="select_name",
id=AnyValue(),
hx_post=f"{ROUTE_ROOT}{Routes.Bindings}",
hx_swap_oob="true"
)
assert matches(res, [expected])
def test_i_can_change_selection(self):
data = Data("")
select_elt = Select(
Option("Option 1", value="option1"),
Option("Option 2", value="option2"),
Option("Option 3", value="option3"),
name="select_name"
)
binding = Binding(data)
mk.manage_binding(select_elt, binding)
binding.update({"select_name": "option2"})
res = binding.update({"select_name": "option1"})
expected = Select(
Option("Option 1", value="option1", selected="true"),
Option(AttributeForbidden("selected"), "Option 2", value="option2"),
Option(AttributeForbidden("selected"), "Option 3", value="option3"),
name="select_name",
id=AnyValue(),
hx_post=f"{ROUTE_ROOT}{Routes.Bindings}",
hx_swap_oob="true"
)
assert matches(res, [expected])
def test_i_can_bind_select_single(self, user, rt):
"""
Single select should bind with data.
Selecting an option should update the label.
"""
@rt("/")
def index():
data = Data("option1")
select_elt = Select(
Option("Option 1", value="option1"),
Option("Option 2", value="option2"),
Option("Option 3", value="option3"),
name="select_name"
)
label_elt = Label()
mk.manage_binding(select_elt, Binding(data))
mk.manage_binding(label_elt, Binding(data))
return select_elt, label_elt
user.open("/")
user.should_see("option1")
testable_select = user.find_element("select")
testable_select.select("option2")
user.should_see("option2")
testable_select.select("option3")
user.should_see("option3")
def test_i_can_bind_select_by_text(self, user, rt):
"""
Selecting by visible text should work with binding.
"""
@rt("/")
def index():
data = Data("opt1")
select_elt = Select(
Option("First Option", value="opt1"),
Option("Second Option", value="opt2"),
Option("Third Option", value="opt3"),
name="select_name"
)
label_elt = Label()
mk.manage_binding(select_elt, Binding(data))
mk.manage_binding(label_elt, Binding(data))
return select_elt, label_elt
user.open("/")
user.should_see("opt1")
testable_select = user.find_element("select")
testable_select.select_by_text("Second Option")
user.should_see("opt2")
def test_select_with_default_selected_option(self, user, rt):
"""
Select with a pre-selected option should initialize correctly.
"""
@rt("/")
def index():
data = Data("option2")
select_elt = Select(
Option("Option 1", value="option1"),
Option("Option 2", value="option2", selected=True),
Option("Option 3", value="option3"),
name="select_name"
)
label_elt = Label()
mk.manage_binding(select_elt, Binding(data))
mk.manage_binding(label_elt, Binding(data))
return select_elt, label_elt
user.open("/")
user.should_see("option2")
class TestBindingSelectMultiple:
"""Tests for binding Select components with multiple selection."""
def test_i_can_bind_select_multiple(self):
data = Data("")
select_elt = Select(
Option("Option 1", value="option1"),
Option("Option 2", value="option2"),
Option("Option 3", value="option3"),
name="select_name",
multiple=True
)
binding = Binding(data)
updated = mk.manage_binding(select_elt, binding)
expected = Select(
AttributeForbidden("hx_swap_oob"),
Option("Option 1", value="option1"),
Option("Option 2", value="option2"),
Option("Option 3", value="option3"),
name="select_name",
id=AnyValue(),
hx_post=f"{ROUTE_ROOT}{Routes.Bindings}",
)
assert matches(updated, expected)
def test_i_can_update_one_selection(self):
data = Data("")
select_elt = Select(
Option("Option 1", value="option1"),
Option("Option 2", value="option2"),
Option("Option 3", value="option3"),
name="select_name",
multiple=True
)
binding = Binding(data)
mk.manage_binding(select_elt, binding)
res = binding.update({"select_name": "option2"})
expected = Select(
Option("Option 1", value="option1"),
Option("Option 2", value="option2", selected="true"),
Option("Option 3", value="option3"),
name="select_name",
id=AnyValue(),
hx_post=f"{ROUTE_ROOT}{Routes.Bindings}",
hx_swap_oob="true"
)
assert matches(res, [expected])
def test_i_can_update_multiple_selections(self):
data = Data("")
select_elt = Select(
Option("Option 1", value="option1"),
Option("Option 2", value="option2"),
Option("Option 3", value="option3"),
name="select_name",
multiple=True
)
binding = Binding(data)
mk.manage_binding(select_elt, binding)
res = binding.update({"select_name": ["option2", "option3"]})
expected = Select(
Option(AttributeForbidden("selected"), "Option 1", value="option1"),
Option("Option 2", value="option2", selected="true"),
Option("Option 3", value="option3", selected="true"),
name="select_name",
id=AnyValue(),
hx_post=f"{ROUTE_ROOT}{Routes.Bindings}",
hx_swap_oob="true"
)
assert matches(res, [expected])
def test_i_can_update_unselect(self):
data = Data(["option1", "option2", "option3"])
select_elt = Select(
Option("Option 1", value="option1", selected="true"),
Option("Option 2", value="option2", selected="true"),
Option("Option 3", value="option3", selected="true"),
name="select_name",
multiple=True
)
binding = Binding(data)
mk.manage_binding(select_elt, binding)
res = binding.update({})
expected = Select(
Option(AttributeForbidden("selected"), "Option 1", value="option1"),
Option(AttributeForbidden("selected"), "Option 2", value="option2"),
Option(AttributeForbidden("selected"), "Option 3", value="option3"),
name="select_name",
id=AnyValue(),
hx_post=f"{ROUTE_ROOT}{Routes.Bindings}",
hx_swap_oob="true"
)
assert matches(res, [expected])
def test_i_can_bind_select_multiple_with_label(self, user, rt):
"""
Multiple select should bind with list data.
Selecting multiple options should update the label.
"""
@rt("/")
def index():
data = ListData(["option1"])
select_elt = Select(
Option("Option 1", value="option1"),
Option("Option 2", value="option2"),
Option("Option 3", value="option3"),
name="select_name",
multiple=True
)
label_elt = Label()
mk.manage_binding(select_elt, Binding(data))
mk.manage_binding(label_elt, Binding(data))
return select_elt, label_elt
user.open("/")
user.should_see("['option1']")
testable_select = user.find_element("select")
testable_select.select("option2")
user.should_see("['option1', 'option2']")
testable_select.select("option3")
user.should_see("['option1', 'option2', 'option3']")
def test_i_can_deselect_from_multiple_select(self, user, rt):
"""
Deselecting options from multiple select should update binding.
"""
@rt("/")
def index():
data = ListData(["option1", "option2"])
select_elt = Select(
Option("Option 1", value="option1"),
Option("Option 2", value="option2"),
Option("Option 3", value="option3"),
name="select_name",
multiple=True
)
label_elt = Label()
mk.manage_binding(select_elt, Binding(data))
mk.manage_binding(label_elt, Binding(data))
return select_elt, label_elt
user.open("/")
user.should_see("['option1', 'option2']")
testable_select = user.find_element("select")
testable_select.deselect("option1")
user.should_see("option2")
class TestBindingRange:
"""Tests for binding Range (slider) components."""
def test_i_can_bind_range(self):
data = Data(50)
range_elt = Input(
type="range",
name="range_name",
min="0",
max="100",
value="50"
)
binding = Binding(data)
updated = mk.manage_binding(range_elt, binding)
expected = Input(
AttributeForbidden("hx_swap_oob"),
type="range",
name="range_name",
min="0",
max="100",
value=50,
hx_post=f"{ROUTE_ROOT}{Routes.Bindings}",
id=AnyValue(),
)
assert matches(updated, expected)
def test_i_can_update_range(self):
data = Data(50)
range_elt = Input(
type="range",
name="range_name",
min="0",
max="100",
value="50"
)
binding = Binding(data)
mk.manage_binding(range_elt, binding)
res = binding.update({"range_name": 25})
expected = [Input(
type="range",
name="range_name",
min="0",
max="100",
value=25,
hx_post=f"{ROUTE_ROOT}{Routes.Bindings}",
id=AnyValue(),
hx_swap_oob="true"
)]
assert matches(res, expected)
def test_i_can_bind_range_with_label(self, user, rt):
"""
Range input should bind with numeric data.
Changing the slider should update the label.
"""
@rt("/")
def index():
data = NumericData(50)
range_elt = Input(
type="range",
name="range_name",
min="0",
max="100",
value="50"
)
label_elt = Label()
mk.manage_binding(range_elt, Binding(data))
mk.manage_binding(label_elt, Binding(data))
return range_elt, label_elt
user.open("/")
user.should_see("50")
testable_range = user.find_element("input[type='range']")
testable_range.set(75)
user.should_see("75")
testable_range.set(25)
user.should_see("25")
def test_range_increase_decrease(self, user, rt):
"""
Increasing and decreasing range should update binding.
"""
@rt("/")
def index():
data = NumericData(50)
range_elt = Input(
type="range",
name="range_name",
min="0",
max="100",
step="10",
value="50"
)
label_elt = Label()
mk.manage_binding(range_elt, Binding(data))
mk.manage_binding(label_elt, Binding(data))
return range_elt, label_elt
user.open("/")
user.should_see("50")
testable_range = user.find_element("input[type='range']")
testable_range.increase()
user.should_see("60")
testable_range.increase()
user.should_see("70")
testable_range.decrease()
user.should_see("60")
def test_range_clamping_to_min_max(self, user, rt):
"""
Range values should be clamped to min/max bounds.
"""
@rt("/")
def index():
data = NumericData(50)
range_elt = Input(
type="range",
name="range_name",
min="0",
max="100",
value="50"
)
label_elt = Label()
mk.manage_binding(range_elt, Binding(data))
mk.manage_binding(label_elt, Binding(data))
return range_elt, label_elt
user.open("/")
testable_range = user.find_element("input[type='range']")
testable_range.set(150) # Above max
user.should_see("100")
testable_range.set(-10) # Below min
user.should_see("0")
class TestBindingRadio:
"""Tests for binding Radio button components."""
def test_i_can_bind_radio_buttons(self):
data = Data()
radio1 = Input(type="radio", name="radio_name", value="option1")
radio2 = Input(type="radio", name="radio_name", value="option2")
radio3 = Input(type="radio", name="radio_name", value="option3")
binding = Binding(data)
mk.manage_binding(radio1, binding)
mk.manage_binding(radio2, Binding(data))
mk.manage_binding(radio3, Binding(data))
res = binding.update({"radio_name": "option1"}) # option1 is selected
expected = [
Input(type="radio", name="radio_name", value="option1", checked="true", hx_swap_oob="true"),
Input(AttributeForbidden("checked"), type="radio", name="radio_name", value="option2", hx_swap_oob="true"),
Input(AttributeForbidden("checked"), type="radio", name="radio_name", value="option3", hx_swap_oob="true"),
]
assert matches(res, expected)
def test_i_can_bind_radio_buttons_and_label(self, user, rt):
"""
Radio buttons should bind with data.
Selecting a radio should update the label.
"""
@rt("/")
def index():
data = Data()
radio1 = Input(type="radio", name="radio_name", value="option1", checked="true")
radio2 = Input(type="radio", name="radio_name", value="option2")
radio3 = Input(type="radio", name="radio_name", value="option3")
label_elt = Label()
mk.manage_binding(radio1, Binding(data))
mk.manage_binding(radio2, Binding(data))
mk.manage_binding(radio3, Binding(data))
mk.manage_binding(label_elt, Binding(data))
return radio1, radio2, radio3, label_elt
user.open("/")
# Select second radio
testable_radio2 = user.find_element("input[value='option2']")
testable_radio2.select()
user.should_see("option2")
# Select third radio
testable_radio3 = user.find_element("input[value='option3']")
testable_radio3.select()
user.should_see("option3")
def test_radio_initial_state(self, user, rt):
"""
Radio buttons should initialize with correct checked state.
"""
@rt("/")
def index():
data = Data("option2")
radio1 = Input(type="radio", name="radio_name", value="option1")
radio2 = Input(type="radio", name="radio_name", value="option2", checked=True)
radio3 = Input(type="radio", name="radio_name", value="option3")
label_elt = Label()
mk.manage_binding(radio1, Binding(data))
mk.manage_binding(radio2, Binding(data))
mk.manage_binding(radio3, Binding(data))
mk.manage_binding(label_elt, Binding(data))
return radio1, radio2, radio3, label_elt
user.open("/")
user.should_see("option2")
class TestBindingDatalist:
"""Tests for binding Input with Datalist (combobox)."""
def test_i_can_bind_datalist(self):
data = Data(["suggestion2"])
datalist = Datalist(
Option(value="suggestion1"),
id="suggestions"
)
updated = mk.manage_binding(datalist, Binding(data))
expected = Datalist(
Option(value="suggestion2"),
id="suggestions"
)
assert matches(updated, expected)
class TestBindingEdgeCases:
"""Tests for edge cases and special scenarios."""
def test_multiple_components_bind_to_same_data(self, user, rt):
"""
Multiple different components can bind to the same data object.
"""
@rt("/")
def index():
data = Data("synchronized")
input_elt = Input(name="input_name")
textarea_elt = Textarea(name="textarea_name")
label_elt = Label()
mk.manage_binding(input_elt, Binding(data))
mk.manage_binding(textarea_elt, Binding(data))
mk.manage_binding(label_elt, Binding(data))
return input_elt, textarea_elt, label_elt
user.open("/")
user.should_see("synchronized")
# Change via input
testable_input = user.find_element("input")
testable_input.send("changed via input")
user.should_see("changed via input")
# Change via textarea
testable_textarea = user.find_element("textarea")
testable_textarea.send("changed via textarea")
user.should_see("changed via textarea")
def test_component_without_name_attribute(self, user, rt):
"""
Component without name attribute should handle gracefully.
"""
@rt("/")
def index():
data = Data("test")
# Input without name - should not crash
input_elt = Input() # No name attribute
label_elt = Label()
mk.manage_binding(label_elt, Binding(data))
return input_elt, label_elt
user.open("/")
user.should_see("test")
def test_binding_with_initial_empty_string(self, user, rt):
"""
Binding should work correctly with empty string initial values.
"""
@rt("/")
def index():
data = Data("")
input_elt = Input(name="input_name")
label_elt = Label()
mk.manage_binding(input_elt, Binding(data))
mk.manage_binding(label_elt, Binding(data))
return input_elt, label_elt
user.open("/")
testable_input = user.find_element("input")
testable_input.send("now has value")
user.should_see("now has value")
def test_binding_with_special_characters(self, user, rt):
"""
Binding should handle special characters correctly.
"""
@rt("/")
def index():
data = Data("Hello")
input_elt = Input(name="input_name")
label_elt = Label()
mk.manage_binding(input_elt, Binding(data))
mk.manage_binding(label_elt, Binding(data))
return input_elt, label_elt
user.open("/")
testable_input = user.find_element("input")
testable_input.send("Special: <>&\"'")
user.should_see("Special: <>&\"'")
class TestCheckBox:
def test_i_can_bind_checkbox(self):
data = Data("")
check_box = Input(name="checkbox_name", type="checkbox")
binding = Binding(data)
mk.manage_binding(check_box, binding)
# checkbox is selected
res = binding.update({"checkbox_name": "on"})
expected = [Input(name="checkbox_name", type="checkbox", checked="true", hx_swap_oob="true")]
assert matches(res, expected)
# check box is not selected
res = binding.update({})
expected = [Input(AttributeForbidden("checked"), name="checkbox_name", type="checkbox", hx_swap_oob="true")]
assert matches(res, expected)
def test_checkbox_initial_state_false(self):
data = Data(False)
check_box = Input(name="checkbox_name", type="checkbox")
binding = Binding(data)
updated = mk.manage_binding(check_box, binding)
expected = Input(AttributeForbidden("checked"), name="checkbox_name", type="checkbox")
assert matches(updated, expected)
def test_checkbox_initial_state_true(self):
data = Data(True)
check_box = Input(name="checkbox_name", type="checkbox")
binding = Binding(data)
updated = mk.manage_binding(check_box, binding)
expected = Input(name="checkbox_name", type="checkbox", checked="true")
assert matches(updated, expected)
def test_i_can_bind_checkbox_and_label_without_converter(self, user, rt):
@rt("/")
def index():
data = Data(True)
input_elt = Input(name="input_name", type="checkbox")
label_elt = Label()
mk.manage_binding(input_elt, Binding(data))
mk.manage_binding(label_elt, Binding(data))
return input_elt, label_elt
user.open("/")
user.should_see("True")
testable_input = user.find_element("input")
testable_input.check()
user.should_see("on")
testable_input.uncheck()
user.should_not_see("on")
def test_i_can_bind_checkbox_and_label_with_converter(self, user, rt):
@rt("/")
def index():
data = Data(True)
input_elt = Input(name="input_name", type="checkbox")
label_elt = Label()
mk.manage_binding(input_elt, Binding(data))
mk.manage_binding(label_elt, Binding(data, converter=BooleanConverter()))
return input_elt, label_elt
user.open("/")
user.should_see("True")
testable_input = user.find_element("input")
testable_input.check()
user.should_see("True")
testable_input.uncheck()
user.should_see("False")

0
tests/core/__init__.py Normal file
View File

383
tests/core/test_bindings.py Normal file
View File

@@ -0,0 +1,383 @@
from dataclasses import dataclass
import pytest
from fasthtml.components import Label, Input
from myutils.observable import collect_return_values
from myfasthtml.core.bindings import (
BindingsManager,
Binding,
DetectionMode,
UpdateMode,
BooleanConverter
)
@dataclass
class Data:
value: str = "Hello World"
@pytest.fixture(autouse=True)
def reset_binding_manager():
BindingsManager.reset()
@pytest.fixture()
def data():
return Data()
def test_i_can_register_a_binding(data):
binding = Binding(data, "value")
assert binding.id is not None
assert binding.data is data
assert binding.data_attr == 'value'
def test_i_can_register_a_binding_with_default_attr(data):
binding = Binding(data)
assert binding.id is not None
assert binding.data is data
assert binding.data_attr == 'value'
def test_i_can_retrieve_a_registered_binding(data):
elt = Label("hello", id="label_id")
binding = Binding(data).bind_ft(elt, name="label_name")
assert BindingsManager.get_binding(binding.id) is binding
def test_i_can_reset_bindings(data):
elt = Label("hello", id="label_id")
Binding(data).bind_ft(elt, name="label_name")
assert len(BindingsManager.bindings) != 0
BindingsManager.reset()
assert len(BindingsManager.bindings) == 0
def test_i_can_bind_an_element_to_a_binding(data):
elt = Label("hello", id="label_id")
Binding(data).bind_ft(elt, name="label_name")
data.value = "new value"
assert elt.children[0] == "new value"
assert elt.attrs["hx-swap-oob"] == "true"
assert elt.attrs["id"] == "label_id"
def test_i_can_bind_an_element_attr_to_a_binding(data):
elt = Input(value="some value", id="input_id")
Binding(data).bind_ft(elt, name="input_name", attr="value")
data.value = "new value"
assert elt.attrs["value"] == "new value"
assert elt.attrs["hx-swap-oob"] == "true"
assert elt.attrs["id"] == "input_id"
def test_bound_element_has_an_id():
elt = Label("hello")
assert elt.attrs.get("id", None) is None
Binding(Data()).bind_ft(elt, name="label_name")
assert elt.attrs.get("id", None) is not None
def test_i_can_collect_updates_values(data):
elt = Label("hello")
Binding(data).bind_ft(elt, name="label_name")
data.value = "new value"
collected = collect_return_values(data)
assert collected == [elt]
# a second time to ensure no side effect
data.value = "another value"
collected = collect_return_values(data)
assert collected == [elt]
def test_i_can_react_to_value_change(data):
elt = Input(name="input_elt", value="hello")
binding = Binding(data).bind_ft(elt, name="input_elt", attr="value")
res = binding.update({"input_elt": "new value"})
assert len(res) == 1
def test_i_do_not_react_to_other_value_change(data):
elt = Input(name="input_elt", value="hello")
binding = Binding(data).bind_ft(elt, name="input_elt", attr="value")
res = binding.update({"other_input_elt": "new value"})
assert res is None
def test_i_can_react_to_attr_presence(data):
elt = Input(name="input_elt", type="checkbox")
binding = Binding(data).bind_ft(
elt,
name="input_elt",
attr="checked",
detection_mode=DetectionMode.AttributePresence
)
res = binding.update({"checked": "true"})
assert len(res) == 1
def test_i_can_react_to_attr_non_presence(data):
elt = Input(name="input_elt", type="checkbox")
binding = Binding(data).bind_ft(
elt,
name="input_elt",
attr="checked",
detection_mode=DetectionMode.AttributePresence
)
res = binding.update({})
assert len(res) == 1
def test_i_can_create_a_binding_without_activation(data):
"""
A binding created without calling bind_ft should not be active.
"""
binding = Binding(data, "value")
assert binding._is_active is False
assert binding.ft is None
assert binding.ft_name is None
assert binding.ft_attr is None
assert BindingsManager.get_binding(binding.id) is None
def test_i_can_activate_binding_via_bind_ft(data):
"""
Calling bind_ft should automatically activate the binding.
"""
elt = Label("hello", id="label_id")
binding = Binding(data, "value")
binding.bind_ft(elt, name="label_name")
assert binding._is_active is True
assert binding.ft is elt
assert binding.ft_name == "label_name"
assert BindingsManager.get_binding(binding.id) is binding
def test_i_cannot_notify_when_not_active(data):
"""
A non-active binding should not update the UI when data changes.
"""
elt = Label("hello", id="label_id")
binding = Binding(data, "value")
binding.ft = elt
binding.ft_name = "label_name"
# Change data without activating the binding
result = binding.notify("old", "new")
assert result is None
assert elt.children[0] == "hello" # Should not have changed
def test_i_can_deactivate_a_binding(data):
"""
Deactivating a binding should clean up observers and unregister it.
"""
elt = Label("hello", id="label_id")
binding = Binding(data, "value").bind_ft(elt, name="label_name")
assert binding._is_active is True
assert BindingsManager.get_binding(binding.id) is binding
binding.deactivate()
assert binding._is_active is False
assert BindingsManager.get_binding(binding.id) is None
def test_i_can_reactivate_a_binding(data):
"""
After deactivation, a binding can be reactivated by calling bind_ft again.
"""
elt1 = Label("hello", id="label_id_1")
binding = Binding(data, "value").bind_ft(elt1, name="label_name_1")
binding.deactivate()
assert binding._is_active is False
elt2 = Label("world", id="label_id_2")
binding.bind_ft(elt2, name="label_name_2")
assert binding._is_active is True
assert binding.ft is elt2
assert binding.ft_name == "label_name_2"
def test_bind_ft_deactivates_before_reconfiguring(data):
"""
Calling bind_ft on an active binding should deactivate it first,
then reconfigure and reactivate.
"""
elt1 = Label("hello", id="label_id_1")
elt2 = Label("world", id="label_id_2")
binding = Binding(data, "value").bind_ft(elt1, name="label_name_1")
# Change data to verify old binding works
data.value = "updated"
assert elt1.children[0] == "updated"
# Reconfigure with new element
binding.bind_ft(elt2, name="label_name_2")
# Change data again
data.value = "final"
# Old element should not update
assert elt1.children[0] == "updated"
# New element should update
assert elt2.children[0] == "final"
def test_deactivate_can_be_called_multiple_times(data):
"""
Calling deactivate multiple times should be safe (idempotent).
"""
elt = Label("hello", id="label_id")
binding = Binding(data, "value").bind_ft(elt, name="label_name")
binding.deactivate()
binding.deactivate() # Should not raise an error
binding.deactivate() # Should not raise an error
assert binding._is_active is False
def test_i_cannot_activate_without_configuration(data):
"""
Calling activate directly without proper configuration should raise ValueError.
"""
binding = Binding(data, "value")
with pytest.raises(ValueError, match="ft element is required"):
binding.activate()
def test_activation_validates_strategies(data):
"""
Activation should fail if detection/update strategies are not initialized.
"""
elt = Label("hello", id="label_id")
binding = Binding(data, "value")
binding.ft = elt
binding.ft_name = "label_name"
with pytest.raises(ValueError, match="detection strategy not initialized"):
binding.activate()
def test_i_can_chain_bind_ft_calls(data):
"""
bind_ft should return self for method chaining.
"""
elt = Label("hello", id="label_id")
binding = Binding(data, "value").bind_ft(elt, name="label_name")
assert isinstance(binding, Binding)
assert binding._is_active is True
def test_bind_ft_updates_optional_parameters(data):
"""
bind_ft should update optional parameters if provided.
"""
elt = Input(name="input_elt", type="checkbox")
binding = Binding(data, "value")
binding.bind_ft(
elt,
name="input_elt",
attr="checked",
data_converter=BooleanConverter(),
detection_mode=DetectionMode.AttributePresence,
update_mode=UpdateMode.AttributePresence
)
assert binding.detection_mode == DetectionMode.AttributePresence
assert binding.update_mode == UpdateMode.AttributePresence
assert isinstance(binding.data_converter, BooleanConverter)
def test_deactivated_binding_does_not_update_on_data_change(data):
"""
After deactivation, changes to data should not update the UI element.
"""
elt = Label("hello", id="label_id")
binding = Binding(data, "value").bind_ft(elt, name="label_name")
# Verify it works when active
data.value = "first update"
assert elt.children[0] == "first update"
# Deactivate
binding.deactivate()
# Change data - element should NOT update
data.value = "second update"
assert elt.children[0] == "first update"
def test_multiple_bindings_can_coexist(data):
"""
Multiple bindings can be created and managed independently.
"""
elt1 = Label("hello", id="label_id_1")
elt2 = Input(value="world", id="input_id_2")
binding1 = Binding(data, "value").bind_ft(elt1, name="label_name")
binding2 = Binding(data, "value").bind_ft(elt2, name="input_name", attr="value")
assert len(BindingsManager.bindings) == 2
assert binding1._is_active is True
assert binding2._is_active is True
# Change data - both should update
data.value = "updated"
assert elt1.children[0] == "updated"
assert elt2.attrs["value"] == "updated"
# Deactivate one
binding1.deactivate()
assert len(BindingsManager.bindings) == 1
# Change data - only binding2 should update
data.value = "final"
assert elt1.children[0] == "updated" # Not changed
assert elt2.attrs["value"] == "final" # Changed
def test_i_cannot_bind_when_htmx_post_already_set(data):
elt = Input(name="input_elt", hx_post="/some/url")
binding = Binding(data, "value")
with pytest.raises(ValueError, match="htmx post already set on input"):
binding.bind_ft(elt, name="label_name")

View File

@@ -0,0 +1,95 @@
from dataclasses import dataclass
from typing import Any
import pytest
from fasthtml.components import Button
from myutils.observable import make_observable, bind
from myfasthtml.core.commands import Command, CommandsManager
from myfasthtml.core.constants import ROUTE_ROOT, Routes
from myfasthtml.test.matcher import matches
@dataclass
class Data:
value: Any
def callback():
return "Hello World"
@pytest.fixture(autouse=True)
def reset_command_manager():
CommandsManager.reset()
def test_i_can_create_a_command_with_no_params():
command = Command('test', 'Command description', callback)
assert command.id is not None
assert command.name == 'test'
assert command.description == 'Command description'
assert command.execute() == "Hello World"
def test_command_are_registered():
command = Command('test', 'Command description', callback)
assert CommandsManager.commands.get(str(command.id)) is command
def test_i_can_bind_a_command_to_an_element():
command = Command('test', 'Command description', callback)
elt = Button()
updated = command.bind_ft(elt)
expected = Button(hx_post=f"{ROUTE_ROOT}{Routes.Commands}")
assert matches(updated, expected)
def test_i_can_suppress_swapping_with_target_attr():
command = Command('test', 'Command description', callback).htmx(target=None)
elt = Button()
updated = command.bind_ft(elt)
expected = Button(hx_post=f"{ROUTE_ROOT}{Routes.Commands}", hx_swap="none")
assert matches(updated, expected)
def test_i_can_bind_a_command_to_an_observable():
data = Data("hello")
def on_data_change(old, new):
return old, new
def another_callback():
data.value = "new value"
return "another callback result"
make_observable(data)
bind(data, "value", on_data_change)
command = Command('test', 'Command description', another_callback).bind(data)
res = command.execute()
assert res == ["another callback result", ("hello", "new value")]
def test_i_can_bind_a_command_to_an_observable_2():
data = Data("hello")
def on_data_change(old, new):
return old, new
def another_callback():
data.value = "new value"
return ["another 1", "another 2"]
make_observable(data)
bind(data, "value", on_data_change)
command = Command('test', 'Command description', another_callback).bind(data)
res = command.execute()
assert res == ["another 1", "another 2", ("hello", "new value")]

View File

@@ -1,25 +0,0 @@
import pytest
from myfasthtml.core.commands import Command, CommandsManager
def callback():
return "Hello World"
@pytest.fixture(autouse=True)
def test_reset_command_manager():
CommandsManager.reset()
def test_i_can_create_a_command_with_no_params():
command = Command('test', 'Command description', callback)
assert command.id is not None
assert command.name == 'test'
assert command.description == 'Command description'
assert command.execute() == "Hello World"
def test_command_are_registered():
command = Command('test', 'Command description', callback)
assert CommandsManager.commands.get(str(command.id)) is command

80
tests/test_integration.py Normal file
View File

@@ -0,0 +1,80 @@
from dataclasses import dataclass
from typing import Any
import pytest
from fasthtml.components import Input, Label
from fasthtml.fastapp import fast_app
from myfasthtml.controls.helpers import mk
from myfasthtml.core.bindings import Binding
from myfasthtml.core.commands import Command, CommandsManager
from myfasthtml.test.testclient import MyTestClient, TestableElement
def new_value(value):
return value
@dataclass
class Data:
value: Any
@pytest.fixture()
def user():
test_app, rt = fast_app(default_hdrs=False)
user = MyTestClient(test_app)
return user
@pytest.fixture()
def rt(user):
return user.app.route
class TestingCommand:
def test_i_can_trigger_a_command(self, user):
command = Command('test', 'TestingCommand', new_value, "this is my new value")
testable = TestableElement(user, mk.button('button', command))
testable.click()
assert user.get_content() == "this is my new value"
def test_error_is_raised_when_command_is_not_found(self, user):
command = Command('test', 'TestingCommand', new_value, "this is my new value")
CommandsManager.reset()
testable = TestableElement(user, mk.button('button', command))
with pytest.raises(ValueError) as exc_info:
testable.click()
assert "not found." in str(exc_info.value)
def test_i_can_play_a_complex_scenario(self, user, rt):
command = Command('test', 'TestingCommand', new_value, "this is my new value")
@rt('/')
def get(): return mk.button('button', command)
user.open("/")
user.should_see("button")
user.find_element("button").click()
user.should_see("this is my new value")
class TestingBindings:
def test_i_can_bind_input(self, user, rt):
@rt("/")
def index():
data = Data("hello world")
input_elt = Input(name="input_name")
label_elt = Label()
mk.manage_binding(input_elt, Binding(data))
mk.manage_binding(label_elt, Binding(data))
return input_elt, label_elt
user.open("/")
user.should_see("")
testable_input = user.find_element("input")
testable_input.send("new value")
user.should_see("new value") # the one from the label

View File

@@ -1,53 +0,0 @@
import pytest
from fasthtml.fastapp import fast_app
from myfasthtml.controls.button import mk_button
from myfasthtml.core.commands import Command, CommandsManager
from myfasthtml.core.testclient import MyTestClient, TestableElement
def new_value(value):
return value
@pytest.fixture()
def user():
test_app, rt = fast_app(default_hdrs=False)
user = MyTestClient(test_app)
return user
@pytest.fixture()
def rt(user):
return user.app.route
def test_i_can_trigger_a_command(user):
command = Command('test', 'TestingCommand', new_value, "this is my new value")
testable = TestableElement(user, mk_button('button', command))
testable.click()
assert user.get_content() == "this is my new value"
def test_error_is_raised_when_command_is_not_found(user):
command = Command('test', 'TestingCommand', new_value, "this is my new value")
CommandsManager.reset()
testable = TestableElement(user, mk_button('button', command))
with pytest.raises(ValueError) as exc_info:
testable.click()
assert "not found." in str(exc_info.value)
def test_i_can_play_a_complex_scenario(user, rt):
command = Command('test', 'TestingCommand', new_value, "this is my new value")
@rt('/')
def get(): return mk_button('button', command)
user.open("/")
user.should_see("button")
user.find_element("button").click()
user.should_see("this is my new value")

View File

@@ -2,9 +2,9 @@ import pytest
from fastcore.basics import NotStr
from fasthtml.components import *
from myfasthtml.core.matcher import matches, StartsWith, Contains, DoesNotContain, Empty, DoNotCheck, ErrorOutput, \
ErrorComparisonOutput
from myfasthtml.core.testclient import MyFT
from myfasthtml.test.matcher import matches, StartsWith, Contains, DoesNotContain, Empty, DoNotCheck, ErrorOutput, \
ErrorComparisonOutput, AttributeForbidden, AnyValue
from myfasthtml.test.testclient import MyFT
@pytest.mark.parametrize('actual, expected', [
@@ -17,12 +17,14 @@ from myfasthtml.core.testclient import MyFT
(Div(attr1="valueXXX", attr2="value"), Div(attr1=StartsWith("value"))),
(Div(attr1="before value after", attr2="value"), Div(attr1=Contains("value"))),
(Div(attr1="before after", attr2="value"), Div(attr1=DoesNotContain("value"))),
(Div(attr1="value"), Div(attr1=AnyValue())),
(None, DoNotCheck()),
(123, DoNotCheck()),
(Div(), DoNotCheck()),
([Div(), Span()], DoNotCheck()),
(NotStr("123456"), NotStr("123")), # for NotStr, only the beginning is checked
(Div(), Div(Empty())),
(Div(attr1="value1"), Div(AttributeForbidden("attr2"))),
(Div(123), Div(123)),
(Div(Span(123)), Div(Span(123))),
(Div(Span(123)), Div(DoNotCheck())),
@@ -48,10 +50,12 @@ def test_i_can_match(actual, expected):
(Div(attr1="value1"), Div(attr1=StartsWith("value2")), "The condition 'StartsWith(value2)' is not satisfied"),
(Div(attr1="value1"), Div(attr1=Contains("value2")), "The condition 'Contains(value2)' is not satisfied"),
(Div(attr1="value1 value2"), Div(attr1=DoesNotContain("value2")), "The condition 'DoesNotContain(value2)'"),
(Div(attr1=None), Div(attr1=AnyValue()), "'attr1' is not found in Actual"),
(Div(), Div(attr1=AnyValue()), "'attr1' is not found in Actual"),
(NotStr("456"), NotStr("123"), "Notstr values are different"),
(Div(attr="value"), Div(Empty()), "Actual is not empty"),
(Div(120), Div(Empty()), "Actual is not empty"),
(Div(Span()), Div(Empty()), "Actual is not empty"),
(Div(attr="value"), Div(Empty()), "The condition 'Empty()' is not satisfied"),
(Div(120), Div(Empty()), "The condition 'Empty()' is not satisfied"),
(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"),
@@ -59,6 +63,7 @@ def test_i_can_match(actual, expected):
(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(attr1="value1"), Div(AttributeForbidden("attr1")), "condition 'AttributeForbidden(attr1)' is not satisfied"),
])
def test_i_can_detect_errors(actual, expected, error_message):
with pytest.raises(AssertionError) as exc_info:
@@ -174,6 +179,7 @@ def test_i_can_output_error_when_predicate():
def test_i_can_output_error_when_predicate_wrong_value():
"""I can display error when the condition predicate is not satisfied."""
elt = "before after"
expected = Contains("value")
path = ""
@@ -184,6 +190,7 @@ def test_i_can_output_error_when_predicate_wrong_value():
def test_i_can_output_error_child_element():
"""I can display error when the element has children"""
elt = Div(P(id="p_id"), Div(id="child_1"), Div(id="child_2"), attr1="value1")
expected = elt
path = ""
@@ -196,6 +203,19 @@ def test_i_can_output_error_child_element():
')',
]
def test_i_can_output_error_child_element_text():
"""I can display error when the children is not a FT"""
elt = Div("Hello world", Div(id="child_1"), Div(id="child_2"), attr1="value1")
expected = elt
path = ""
error_output = ErrorOutput(path, elt, expected)
error_output.compute()
assert error_output.output == ['(div "attr1"="value1"',
' "Hello world"',
' (div "id"="child_1")',
' (div "id"="child_2")',
')',
]
def test_i_can_output_error_child_element_indicating_sub_children():
elt = Div(P(id="p_id"), Div(Div(id="child_2"), id="child_1"), attr1="value1")

View File

@@ -2,7 +2,7 @@ import pytest
from fasthtml.components import Div
from fasthtml.fastapp import fast_app
from myfasthtml.core.testclient import MyTestClient, TestableElement, TestableForm
from myfasthtml.test.testclient import MyTestClient, TestableElement, TestableForm
class TestMyTestClientOpen:
@@ -481,4 +481,3 @@ class TestMyTestClientFindForm:
error_message = str(exc_info.value)
assert "Found 2 forms (with the specified fields). Expected exactly 1." in error_message

View File

@@ -0,0 +1,89 @@
from dataclasses import dataclass
import pytest
from fasthtml.fastapp import fast_app
from myfasthtml.test.testclient import MyTestClient, TestableRadio
@dataclass
class Data:
value: str = "hello world"
@pytest.fixture
def test_app():
test_app, rt = fast_app(default_hdrs=False)
return test_app
@pytest.fixture
def rt(test_app):
return test_app.route
@pytest.fixture
def test_client(test_app):
return MyTestClient(test_app)
def test_i_can_read_not_selected_radio(test_client):
html = '''<input type="radio" name="radio_name" value="option1" />'''
input_elt = TestableRadio(test_client, html)
assert input_elt.name == "radio_name"
assert input_elt.value is None
def test_i_can_read_selected_radio(test_client):
html = '''<input type="radio" name="radio_name" value="option1" checked="true"/>'''
input_elt = TestableRadio(test_client, html)
assert input_elt.name == "radio_name"
assert input_elt.value == "option1"
def test_i_cannot_read_radio_with_multiple_values(test_client):
html = '''
<input type="radio" name="radio_name" value="option1" checked="true" />
<input type="radio" name="radio_name" value="option2" />
'''
with pytest.raises(AssertionError) as exc_info:
TestableRadio(test_client, html)
assert "Only one radio button per name is supported" in str(exc_info.value)
def test_i_cannot_read_radio_when_no_radio_button(test_client):
html = '''
<input type="text" name="radio_name" value="option1" checked="true" /> '''
with pytest.raises(AssertionError) as exc_info:
TestableRadio(test_client, html)
assert "No radio buttons found" in str(exc_info.value)
def test_i_can_read_input_with_label(test_client):
html = '''<label for="uid">John Doe</label><input id="uid" type="radio" name="username" value="john_doe" />'''
input_elt = TestableRadio(test_client, html)
assert input_elt.fields_mapping == {"John Doe": "username"}
assert input_elt.name == "username"
assert input_elt.value is None
def test_i_can_send_values(test_client, rt):
html = '''<input type="text" name="username" type="radio" value="john_doe" hx_post="/submit"/>'''
@rt('/submit')
def post(username: str):
return f"Input received {username=}"
input_elt = TestableRadio(test_client, html)
input_elt.select()
assert test_client.get_content() == "Input received username='john_doe'"

View File

@@ -0,0 +1,165 @@
"""
Comprehensive binding tests for all bindable FastHTML components.
This test suite covers:
- Input (text) - already tested
- Checkbox - already tested
- Textarea
- Select (single)
- Select (multiple)
- Range (slider)
- Radio buttons
- Button
- Input with Datalist (combobox)
"""
from dataclasses import dataclass
import pytest
from fasthtml.components import (
Input, Label, Textarea
)
from fasthtml.fastapp import fast_app
from myfasthtml.controls.helpers import mk
from myfasthtml.core.bindings import Binding
from myfasthtml.test.testclient import MyTestClient
@dataclass
class Data:
value: str = "hello world"
@dataclass
class NumericData:
value: int = 50
@dataclass
class BoolData:
value: bool = True
@dataclass
class ListData:
value: list = None
def __post_init__(self):
if self.value is None:
self.value = []
@pytest.fixture
def test_app():
test_app, rt = fast_app(default_hdrs=False)
return test_app
@pytest.fixture
def rt(test_app):
return test_app.route
@pytest.fixture
def user(test_app):
return MyTestClient(test_app)
class TestBindingEdgeCases:
"""Tests for edge cases and special scenarios."""
def test_multiple_components_bind_to_same_data(self, user, rt):
"""
Multiple different components can bind to the same data object.
"""
@rt("/")
def index():
data = Data("synchronized")
input_elt = Input(name="input_name")
textarea_elt = Textarea(name="textarea_name")
label_elt = Label()
mk.manage_binding(input_elt, Binding(data))
mk.manage_binding(textarea_elt, Binding(data))
mk.manage_binding(label_elt, Binding(data))
return input_elt, textarea_elt, label_elt
user.open("/")
user.should_see("synchronized")
# Change via input
testable_input = user.find_element("input")
testable_input.send("changed via input")
user.should_see("changed via input")
# Change via textarea
testable_textarea = user.find_element("textarea")
testable_textarea.send("changed via textarea")
user.should_see("changed via textarea")
def test_component_without_name_attribute(self, user, rt):
"""
Component without name attribute should handle gracefully.
"""
@rt("/")
def index():
data = Data("test")
# Input without name - should not crash
input_elt = Input() # No name attribute
label_elt = Label()
mk.manage_binding(label_elt, Binding(data))
return input_elt, label_elt
user.open("/")
user.should_see("test")
def test_binding_with_initial_empty_string(self, user, rt):
"""
Binding should work correctly with empty string initial values.
"""
@rt("/")
def index():
data = Data("")
input_elt = Input(name="input_name")
label_elt = Label()
mk.manage_binding(input_elt, Binding(data))
mk.manage_binding(label_elt, Binding(data))
return input_elt, label_elt
user.open("/")
testable_input = user.find_element("input")
testable_input.send("now has value")
user.should_see("now has value")
def test_binding_with_special_characters(self, user, rt):
"""
Binding should handle special characters correctly.
"""
@rt("/")
def index():
data = Data("Hello")
input_elt = Input(name="input_name")
label_elt = Label()
mk.manage_binding(input_elt, Binding(data))
mk.manage_binding(label_elt, Binding(data))
return input_elt, label_elt
user.open("/")
testable_input = user.find_element("input")
testable_input.send("Special: <>&\"'")
user.should_see("Special: <>&\"'")

View File

@@ -0,0 +1,59 @@
import pytest
from fasthtml.fastapp import fast_app
from myfasthtml.test.testclient import MyTestClient, TestableCheckbox
@pytest.fixture
def test_app():
test_app, rt = fast_app(default_hdrs=False)
return test_app
@pytest.fixture
def rt(test_app):
return test_app.route
@pytest.fixture
def test_client(test_app):
return MyTestClient(test_app)
@pytest.mark.parametrize("html,expected_value", [
('<input type="checkbox" name="male" checked />', True),
('<input type="checkbox" name="male" />', False),
])
def test_i_can_read_input(test_client, html, expected_value):
input_elt = TestableCheckbox(test_client, html)
assert input_elt.name == "male"
assert input_elt.value == expected_value
def test_i_can_read_input_with_label(test_client):
html = '''<label for="uid">Male</label><input id="uid" type="checkbox" name="male" checked />'''
input_elt = TestableCheckbox(test_client, html)
assert input_elt.fields_mapping == {"Male": "male"}
assert input_elt.name == "male"
assert input_elt.value == True
def test_i_can_check_checkbox(test_client, rt):
html = '''<input type="checkbox" name="male" hx_post="/submit"/>'''
@rt('/submit')
def post(male: bool=None):
return f"Checkbox received {male=}"
input_elt = TestableCheckbox(test_client, html)
input_elt.check()
assert test_client.get_content() == "Checkbox received male=True"
input_elt.uncheck()
assert test_client.get_content() == "Checkbox received male=None"
input_elt.toggle()
assert test_client.get_content() == "Checkbox received male=True"

View File

@@ -2,14 +2,14 @@ import pytest
from fasthtml.components import Div
from fasthtml.fastapp import fast_app
from myfasthtml.core.testclient import MyTestClient, TestableElement, MyFT
from myfasthtml.test.testclient import MyTestClient, TestableElement, MyFT
def test_i_can_create_testable_element_from_ft():
ft = Div("hello world", id="test")
testable_element = TestableElement(None, ft)
assert testable_element.ft == ft
assert testable_element.my_ft == MyFT('div', {'id': 'test'})
assert testable_element.html_fragment == '<div id="test">hello world</div>'
@@ -17,7 +17,7 @@ def test_i_can_create_testable_element_from_str():
ft = '<div id="test">hello world</div>'
testable_element = TestableElement(None, ft)
assert testable_element.ft == MyFT('div', {'id': 'test'})
assert testable_element.my_ft == MyFT('div', {'id': 'test'})
assert testable_element.html_fragment == '<div id="test">hello world</div>'
@@ -27,7 +27,7 @@ def test_i_can_create_testable_element_from_beautifulsoup_element():
tag = BeautifulSoup(ft, 'html.parser').div
testable_element = TestableElement(None, tag)
assert testable_element.ft == MyFT('div', {'id': 'test'})
assert testable_element.my_ft == MyFT('div', {'id': 'test'})
assert testable_element.html_fragment == '<div id="test">hello world</div>'

View File

@@ -1,7 +1,7 @@
import pytest
from fasthtml.fastapp import fast_app
from myfasthtml.core.testclient import TestableForm, MyTestClient
from myfasthtml.test.testclient import TestableForm, MyTestClient
@pytest.fixture
@@ -268,8 +268,7 @@ class TestableFormUpdateFieldValues:
'''
form = TestableForm(mock_client, html)
assert "size" not in form.fields, \
f"Expected 'size' not in fields, got {form.fields}"
assert form.fields == {"size": None}, f"Expected 'size' not in fields, got {form.fields}"
def test_i_can_handle_number_input_with_integer(self, mock_client):
"""

View File

@@ -0,0 +1,58 @@
import pytest
from fasthtml.fastapp import fast_app
from myfasthtml.test.testclient import TestableInput, MyTestClient
@pytest.fixture
def test_app():
test_app, rt = fast_app(default_hdrs=False)
return test_app
@pytest.fixture
def rt(test_app):
return test_app.route
@pytest.fixture
def test_client(test_app):
return MyTestClient(test_app)
def test_i_can_read_input(test_client):
html = '''<input type="text" name="username" value="john_doe" />'''
input_elt = TestableInput(test_client, html)
assert input_elt.name == "username"
assert input_elt.value == "john_doe"
def test_i_can_read_input_with_label(test_client):
html = '''<label for="uid">Username</label><input id="uid" name="username" value="john_doe" />'''
input_elt = TestableInput(test_client, html)
assert input_elt.fields_mapping == {"Username": "username"}
assert input_elt.name == "username"
assert input_elt.value == "john_doe"
def test_i_can_send_values(test_client, rt):
html = '''<input type="text" name="username" value="john_doe" hx_post="/submit"/>'''
@rt('/submit')
def post(username: str):
return f"Input received {username=}"
input_elt = TestableInput(test_client, html)
input_elt.send("another name")
assert test_client.get_content() == "Input received username='another name'"
def i_can_find_input_by_name(test_client):
html = '''<label for="uid">Username</label><input id="uid" name="username" value="john_doe" />'''
element = test_client.find_input("Username")
assert False

View File

@@ -0,0 +1,72 @@
from dataclasses import dataclass
import pytest
from fasthtml.fastapp import fast_app
from myfasthtml.test.testclient import MyTestClient, TestableRange
@dataclass
class Data:
value: str = "hello world"
@pytest.fixture
def test_app():
test_app, rt = fast_app(default_hdrs=False)
return test_app
@pytest.fixture
def rt(test_app):
return test_app.route
@pytest.fixture
def test_client(test_app):
return MyTestClient(test_app)
def test_i_can_read_range(test_client):
html = '''<input type="range" name="range_name" min="0" max="100" step="10" value="50" />'''
input_elt = TestableRange(test_client, html)
assert input_elt.name == "range_name"
assert input_elt.value == 50
assert input_elt.min_value == 0
assert input_elt.max_value == 100
assert input_elt.step == 10
@pytest.mark.parametrize("value, expected", [
(30, 30),
(24, 20), # step 10
(-10, 0), # min 0
(110, 100), # max 100
])
def test_i_can_set_value(test_client, value, expected):
html = '''<input type="range" name="range_name" min="0" max="100" step="10" value="50" />'''
input_elt = TestableRange(test_client, html)
input_elt.set(value)
assert input_elt.value == expected
def test_i_can_increase_value(test_client):
html = '''<input type="range" name="range_name" min="0" max="100" step="10" value="50" />'''
input_elt = TestableRange(test_client, html)
input_elt.increase()
assert input_elt.value == 60
def test_i_can_decrease_value(test_client):
html = '''<input type="range" name="range_name" min="0" max="100" step="10" value="50" />'''
input_elt = TestableRange(test_client, html)
input_elt.decrease()
assert input_elt.value == 40

View File

@@ -0,0 +1,63 @@
import pytest
from fasthtml.fastapp import fast_app
from myfasthtml.test.testclient import TestableSelect, MyTestClient
@pytest.fixture
def test_app():
test_app, rt = fast_app(default_hdrs=False)
return test_app
@pytest.fixture
def rt(test_app):
return test_app.route
@pytest.fixture
def test_client(test_app):
return MyTestClient(test_app)
def test_i_can_read_select(test_client):
html = '''<select name="select_name">
<option value="option1">Option 1</option>
<option value="option2">Option 2</option>
<option value="option3">Option 3</option>
</select>
'''
select_elt = TestableSelect(test_client, html)
assert select_elt.name == "select_name"
assert select_elt.value == "option1" # if no selected found, the first option is selected by default
assert select_elt.options == [{'text': 'Option 1', 'value': 'option1'},
{'text': 'Option 2', 'value': 'option2'},
{'text': 'Option 3', 'value': 'option3'}]
assert select_elt.select_fields == {'select_name': [{'text': 'Option 1', 'value': 'option1'},
{'text': 'Option 2', 'value': 'option2'},
{'text': 'Option 3', 'value': 'option3'}]}
assert select_elt.is_multiple is False
def test_i_can_select_option(test_client):
html = '''<select name="select_name">
<option value="option1">Option 1</option>
<option value="option2">Option 2</option>
<option value="option3">Option 3</option>
</select>
'''
select_elt = TestableSelect(test_client, html)
select_elt.select("option2")
assert select_elt.value == "option2"
def test_i_can_select_by_text(test_client):
html = '''<select name="select_name">
<option value="option1">Option 1</option>
<option value="option2">Option 2</option>
<option value="option3">Option 3</option>
</select>
'''
select_elt = TestableSelect(test_client, html)
select_elt.select_by_text("Option 3")
assert select_elt.value == "option3"

View File

@@ -0,0 +1,107 @@
import pytest
from fasthtml.fastapp import fast_app
from myfasthtml.test.testclient import TestableSelect, MyTestClient
@pytest.fixture
def test_app():
test_app, rt = fast_app(default_hdrs=False)
return test_app
@pytest.fixture
def rt(test_app):
return test_app.route
@pytest.fixture
def test_client(test_app):
return MyTestClient(test_app)
def test_i_can_read_select(test_client):
html = '''<select name="select_name" multiple>
<option value="option1">Option 1</option>
<option value="option2">Option 2</option>
<option value="option3">Option 3</option>
</select>
'''
select_elt = TestableSelect(test_client, html)
assert select_elt.name == "select_name"
assert select_elt.value == []
assert select_elt.options == [{'text': 'Option 1', 'value': 'option1'},
{'text': 'Option 2', 'value': 'option2'},
{'text': 'Option 3', 'value': 'option3'}]
assert select_elt.select_fields == {'select_name': [{'text': 'Option 1', 'value': 'option1'},
{'text': 'Option 2', 'value': 'option2'},
{'text': 'Option 3', 'value': 'option3'}]}
assert select_elt.is_multiple is True
def test_i_can_read_select_with_multiple_selected_values(test_client):
html = '''<select name="select_name" multiple>
<option value="option1" selected>Option 1</option>
<option value="option2">Option 2</option>
<option value="option3" selected>Option 3</option>
</select>
'''
select_elt = TestableSelect(test_client, html)
assert select_elt.name == "select_name"
assert select_elt.value == ["option1", "option3"]
assert select_elt.is_multiple is True
def test_i_can_select_option(test_client):
html = '''<select name="select_name" multiple>
<option value="option1">Option 1</option>
<option value="option2">Option 2</option>
<option value="option3">Option 3</option>
</select>
'''
select_elt = TestableSelect(test_client, html)
select_elt.select("option2")
assert select_elt.value == "option2"
def test_i_can_select_multiple_options(test_client):
html = '''<select name="select_name" multiple>
<option value="option1">Option 1</option>
<option value="option2">Option 2</option>
<option value="option3">Option 3</option>
</select>
'''
select_elt = TestableSelect(test_client, html)
select_elt.select("option2")
select_elt.select("option3")
assert select_elt.value == ["option2", "option3"]
def test_i_can_select_by_text(test_client):
html = '''<select name="select_name" multiple>
<option value="option1">Option 1</option>
<option value="option2">Option 2</option>
<option value="option3">Option 3</option>
</select>
'''
select_elt = TestableSelect(test_client, html)
select_elt.select_by_text("Option 3")
assert select_elt.value == "option3"
def test_i_can_deselect(test_client):
html = '''<select name="select_name" multiple>
<option value="option1" selected>Option 1</option>
<option value="option2" selected>Option 2</option>
<option value="option3" selected>Option 3</option>
</select>
'''
select_elt = TestableSelect(test_client, html)
select_elt.deselect("option3")
assert select_elt.value == ["option1", "option2"]
select_elt.deselect("option2")
assert select_elt.value == "option1"
select_elt.deselect("option1")
assert select_elt.value == []

View File

@@ -0,0 +1,36 @@
from dataclasses import dataclass
import pytest
from fasthtml.fastapp import fast_app
from myfasthtml.test.testclient import MyTestClient, TestableTextarea
@dataclass
class Data:
value: str = "hello world"
@pytest.fixture
def test_app():
test_app, rt = fast_app(default_hdrs=False)
return test_app
@pytest.fixture
def rt(test_app):
return test_app.route
@pytest.fixture
def test_client(test_app):
return MyTestClient(test_app)
def test_i_can_read_input(test_client):
html = '''<textarea name="textarea_name">Lorem ipsum</textarea>'''
input_elt = TestableTextarea(test_client, html)
assert input_elt.name == "textarea_name"
assert input_elt.value == "Lorem ipsum"