Added TestableInput

This commit is contained in:
2025-10-31 21:11:56 +01:00
parent b5c1c15198
commit 3721bb7ad7
15 changed files with 730 additions and 246 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

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,7 +11,8 @@ 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.
- **Control helpers**: Easily create reusable components like buttons.
- **Predefined Pages (Roadmap)**: Include common pages like login, user management, and customizable dashboards.
@@ -31,28 +33,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)
```
### Button with a Command
```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!"
# 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)
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.
@@ -63,32 +96,41 @@ def get_homepage():
## 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}"
my_command = Command("custom", "Handles custom logic", custom_action)
# Get the HTMX parameters automatically
@@ -109,6 +151,7 @@ Use the `get_htmx_params()` method to directly integrate commands into HTML comp
## 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,7 +187,9 @@ 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`).
@@ -152,14 +197,18 @@ Represents a backend action with server communication.
- **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.
#### 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.
@@ -177,7 +226,6 @@ Predefined login page that provides a UI template ready for integration.
No custom exceptions defined yet. (Placeholder for future use.)
## Relase History
* 0.1.0 : First release

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

@@ -32,12 +32,13 @@ class BaseCommand:
self.id = uuid.uuid4()
self.name = name
self.description = description
self.htmx_extra = {}
# 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}"}}',
}
@@ -45,6 +46,11 @@ class BaseCommand:
def execute(self):
raise NotImplementedError
def htmx(self, target=None):
if target:
self.htmx_extra["hx-target"] = target
return self
class Command(BaseCommand):
"""

View File

@@ -17,3 +17,32 @@ 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

View File

@@ -8,6 +8,7 @@ from starlette.responses import Response
from myfasthtml.auth.routes import setup_auth_routes
from myfasthtml.auth.utils import create_auth_beforeware
from myfasthtml.core.commands import commands_app
def get_asset_path(filename):
@@ -25,7 +26,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 +51,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 +85,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
app.mount("/myfasthtml", commands_app)
if mount_auth_app:
# Setup authentication routes
setup_auth_routes(app, rt)

View File

@@ -3,7 +3,7 @@ from dataclasses import dataclass
from fastcore.basics import NotStr
from myfasthtml.core.testclient import MyFT
from myfasthtml.test.testclient import MyFT
class Predicate:

View File

@@ -12,6 +12,14 @@ from starlette.testclient import TestClient
from myfasthtml.core.commands import mount_commands
verbs = {
'hx_get': 'GET',
'hx_post': 'POST',
'hx_put': 'PUT',
'hx_delete': 'DELETE',
'hx_patch': 'PATCH',
}
@dataclass
class MyFT:
@@ -29,7 +37,7 @@ class TestableElement:
or verifying element properties.
"""
def __init__(self, client, source):
def __init__(self, client, source, tag=None):
"""
Initialize a testable element.
@@ -39,18 +47,35 @@ class TestableElement:
"""
self.client = client
if isinstance(source, str):
self.html_fragment = source
tag = BeautifulSoup(source, 'html.parser').find()
self.ft = MyFT(tag.name, tag.attrs)
self.html_fragment = source.strip()
elif isinstance(source, Tag):
self.html_fragment = str(source)
self.ft = MyFT(source.name, source.attrs)
self.html_fragment = str(source).strip()
elif isinstance(source, FT):
self.ft = source
self.html_fragment = to_xml(source).strip()
else:
raise ValueError(f"Invalid source '{source}' for TestableElement.")
self.tag, self.element, self.my_ft = self._parse(tag, self.html_fragment)
self.fields_mapping = {} # link between the input label and the input name
self.fields = {} # Values of the fields {name: value}
self.select_fields = {} # list of possible options for 'select' input fields
self._update_fields_mapping()
self._update_fields()
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 click(self):
"""Click the element (to be implemented)."""
return self._send_htmx_request()
@@ -59,6 +84,26 @@ class TestableElement:
"""Check if element matches given FastHTML element (to be implemented)."""
pass
def _translate(self, field):
"""
Translate a given field using a predefined mapping. If the field is not found
in the mapping, the original field is returned unmodified.
:param field: The field name to be translated.
:type field: str
:return: The translated field name if present in the mapping, or the original
field name if no mapping exists for it.
:rtype: str
"""
return self.fields_mapping.get(field, field)
def _support_htmx(self):
"""Check if the element supports HTMX."""
return ('hx_get' in self.my_ft.attrs or
'hx-get' in self.my_ft.attrs or
'hx_post' in self.my_ft.attrs or
'hx-post' in self.my_ft.attrs)
def _send_htmx_request(self, json_data: dict | None = None, data: dict | None = None) -> Response:
"""
Simulates an HTMX request in Python for unit testing.
@@ -83,16 +128,13 @@ class TestableElement:
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',
}
if data is not None:
headers['Content-Type'] = 'application/x-www-form-urlencoded'
elif json_data is not None:
headers['Content-Type'] = 'application/json'
# .props contains the kwargs passed to the object (e.g., hx_post="/url")
element_attrs = self.ft.attrs or {}
element_attrs = self.my_ft.attrs or {}
# Build the attributes
for key, value in element_attrs.items():
@@ -124,180 +166,13 @@ class TestableElement:
# Sanity check
if url is None:
raise ValueError(
f"The <{self.ft.tag}> element has no HTMX verb attribute "
f"The <{self.my_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.
@@ -319,16 +194,16 @@ class TestableForm(TestableElement):
unnamed_counter = 0
# Get all inputs in the form
all_inputs = self.form.find_all('input')
all_inputs = self.element.find_all('input')
# Priority 1 & 2: Explicit association (for/id) and implicit (nested)
for label in self.form.find_all('label'):
for label in self.element.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)
input_field = self.element.find('input', id=label_for)
if input_field:
input_name = self._get_input_identifier(input_field, unnamed_counter)
if input_name.startswith('unnamed_'):
@@ -348,7 +223,7 @@ class TestableForm(TestableElement):
continue
# Priority 3 & 4: Parent-level associations
for label in self.form.find_all('label'):
for label in self.element.find_all('label'):
label_text = label.get_text(strip=True)
# Skip if this label was already processed
@@ -391,6 +266,89 @@ class TestableForm(TestableElement):
unnamed_counter += 1
self.fields_mapping[input_name] = input_name
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.element.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.element.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 _get_my_ft(self, element: Tag):
_inner = element.find(self.tag) if self.tag and self.tag != element.name else element
return MyFT(_inner.name, _inner.attrs)
@staticmethod
def _get_input_identifier(input_field, counter):
"""
@@ -474,6 +432,456 @@ class TestableForm(TestableElement):
# Default to string
return value
@staticmethod
def _get_element(html_fragment: str):
html_fragment = html_fragment.strip()
if (not html_fragment.startswith('<div') and
not html_fragment.startswith('<form')):
html_fragment = "<div>" + html_fragment + "</div>"
return BeautifulSoup(html_fragment, 'html.parser').find()
@staticmethod
def _parse(tag, html_fragment: str):
elt = BeautifulSoup(html_fragment, 'html.parser')
if len(elt) == 0:
raise ValueError(f"No HTML element found in: {html_fragment}")
if len(elt) == 1:
elt = elt.find()
elt_tag = elt.name
if tag is not None and tag != elt_tag:
raise ValueError(f"Tag '{tag}' does not match with '{html_fragment}'.")
my_ft = MyFT(elt_tag, elt.attrs)
if elt_tag != "form":
elt = BeautifulSoup(f"<form>{html_fragment}</form>", 'html.parser')
return elt_tag, elt, my_ft
else:
if tag is None:
raise ValueError(f"Multiple elements found in {html_fragment}. Please specify a tag.")
elt = BeautifulSoup(f"<form>{html_fragment}</form>", 'html.parser')
_inner = elt.find(tag)
my_ft = MyFT(_inner.name, _inner.attrs)
return tag, elt.find(), my_ft
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, "form")
# 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 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.element.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.element.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):
# """
# Translate a given field using a predefined mapping. If the field is not found
# in the mapping, the original field is returned unmodified.
#
# :param field: The field name to be translated.
# :type field: str
# :return: The translated field name if present in the mapping, or the original
# field name if no mapping exists for it.
# :rtype: str
# """
# 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 TestableInput(TestableElement):
def __init__(self, client, source):
super().__init__(client, source, "input")
assert len(self.fields) <= 1
self._input_name = next(iter(self.fields))
@property
def name(self):
return self._input_name
@property
def value(self):
return self.fields[self._input_name]
def send(self, value):
self.fields[self.name] = value
if self.name and self._support_htmx():
return self._send_htmx_request(data={self.name: self.value})
return None
# def get_value(tag):
# """Return the current user-facing value of an HTML input-like element."""
# if tag.name == 'input':
# t = tag.get('type', 'text').lower()
# if t in ('checkbox', 'radio'):
# # For checkbox/radio: return True/False if checked, else value if defined
# return tag.has_attr('checked')
# return tag.get('value', '')
#
# elif tag.name == 'textarea':
# # Textarea content is its text, not an attribute
# return tag.text or ''
#
# elif tag.name == 'select':
# # For <select>, get selected option value (or text if no value attr)
# selected = tag.find('option', selected=True)
# if selected:
# return selected.get('value', selected.text)
# first = tag.find('option')
# return first.get('value', first.text) if first else ''
#
# else:
# raise TypeError(f"Unsupported tag: <{tag.name}>")
#
#
# def _update_value(tag, new_value):
# """Simulate user input by updating the value of <input>, <textarea>, or <select>."""
# if tag.name == 'input':
# t = tag.get('type', 'text').lower()
# if t in ('checkbox', 'radio'):
# # For checkbox/radio: treat True/False as checked/unchecked
# if isinstance(new_value, bool):
# if new_value:
# tag['checked'] = ''
# elif 'checked' in tag.attrs:
# del tag.attrs['checked']
# else:
# tag['value'] = str(new_value)
# else:
# tag['value'] = str(new_value)
#
# elif tag.name == 'textarea':
# tag.string = str(new_value)
#
# elif tag.name == 'select':
# # Deselect all options
# for option in tag.find_all('option'):
# option.attrs.pop('selected', None)
# # Select matching one by value or text
# matched = tag.find('option', value=str(new_value))
# if matched:
# matched['selected'] = ''
# else:
# matched = tag.find('option', string=str(new_value))
# if matched:
# matched['selected'] = ''
#
# else:
# raise TypeError(f"Unsupported tag: <{tag.name}>")
class MyTestClient:
"""
@@ -566,7 +974,6 @@ class MyTestClient:
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()."
@@ -665,7 +1072,7 @@ class MyTestClient:
f"No element found matching selector '{selector}'."
)
elif len(results) == 1:
return TestableElement(self, results[0])
return TestableElement(self, results[0], results[0].name)
else:
raise AssertionError(
f"Found {len(results)} elements matching selector '{selector}'. Expected exactly 1."
@@ -695,7 +1102,7 @@ class MyTestClient:
remaining = []
for form in results:
testable_form = TestableForm(self, form)
if all(testable_form.translate(field) in testable_form.fields for field in fields):
if all(testable_form._translate(field) in testable_form.fields for field in fields):
remaining.append(testable_form)
if len(remaining) == 1:

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,7 @@ import pytest
from fasthtml.fastapp import fast_app
from myfasthtml.auth.utils import create_auth_beforeware
from myfasthtml.core.testclient import MyTestClient
from myfasthtml.test.testclient import MyTestClient
def test_non_protected_route():
app, rt = fast_app()

View File

@@ -1,9 +1,9 @@
import pytest
from fasthtml.fastapp import fast_app
from myfasthtml.controls.button import mk_button
from myfasthtml.controls.helpers import mk
from myfasthtml.core.commands import Command, CommandsManager
from myfasthtml.core.testclient import MyTestClient, TestableElement
from myfasthtml.test.testclient import MyTestClient, TestableElement
def new_value(value):
@@ -24,7 +24,7 @@ def rt(user):
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 = TestableElement(user, mk.button('button', command))
testable.click()
assert user.get_content() == "this is my new value"
@@ -32,7 +32,7 @@ def test_i_can_trigger_a_command(user):
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))
testable = TestableElement(user, mk.button('button', command))
with pytest.raises(ValueError) as exc_info:
testable.click()
@@ -44,7 +44,7 @@ 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)
def get(): return mk.button('button', command)
user.open("/")
user.should_see("button")

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, \
from myfasthtml.test.matcher import matches, StartsWith, Contains, DoesNotContain, Empty, DoNotCheck, ErrorOutput, \
ErrorComparisonOutput
from myfasthtml.core.testclient import MyFT
from myfasthtml.test.testclient import MyFT
@pytest.mark.parametrize('actual, expected', [

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:

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