I can use commands

This commit is contained in:
2025-10-24 22:58:48 +02:00
parent 0f5fc696f0
commit 063a89f143
5 changed files with 263 additions and 17 deletions

View File

@@ -1,7 +1,21 @@
import json
import uuid
from dataclasses import dataclass
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
class TestableElement:
"""
@@ -11,7 +25,7 @@ class TestableElement:
or verifying element properties.
"""
def __init__(self, client, html_fragment):
def __init__(self, client, source):
"""
Initialize a testable element.
@@ -20,15 +34,98 @@ class TestableElement:
ft: The FastHTML element representation.
"""
self.client = client
self.html_fragment = html_fragment
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)."""
pass
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)
class MyTestClient:
@@ -52,7 +149,11 @@ class MyTestClient:
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):
"""
@@ -67,11 +168,31 @@ class MyTestClient:
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):
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
@@ -190,7 +311,7 @@ class MyTestClient:
f"No element found matching selector '{selector}'."
)
elif len(results) == 1:
return TestableElement(self, str(results[0]))
return TestableElement(self, results[0])
else:
raise AssertionError(
f"Found {len(results)} elements matching selector '{selector}'. Expected exactly 1."