I can use commands
This commit is contained in:
@@ -5,8 +5,9 @@ from typing import Optional
|
||||
from fasthtml.fastapp import fast_app
|
||||
|
||||
from myfasthtml.core.constants import Routes, ROUTE_ROOT
|
||||
from myfasthtml.core.utils import mount_if_not_exists
|
||||
|
||||
commands_app, rt = fast_app()
|
||||
commands_app, commands_rt = fast_app()
|
||||
logger = logging.getLogger("Commands")
|
||||
|
||||
|
||||
@@ -93,7 +94,7 @@ class CommandsManager:
|
||||
return CommandsManager.commands.clear()
|
||||
|
||||
|
||||
@rt(Routes.Commands)
|
||||
@commands_rt(Routes.Commands)
|
||||
def post(session: str, c_id: str):
|
||||
"""
|
||||
Default routes for all commands.
|
||||
@@ -106,4 +107,16 @@ def post(session: str, c_id: str):
|
||||
if command:
|
||||
return command.execute()
|
||||
|
||||
return None
|
||||
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)
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -1,3 +1,19 @@
|
||||
from myfasthtml.core.commands import Command
|
||||
from myfasthtml.core.constants import ROUTE_ROOT, Routes
|
||||
from starlette.routing import Mount
|
||||
|
||||
|
||||
def mount_if_not_exists(app, path: str, sub_app):
|
||||
"""
|
||||
Mounts a sub-application only if no Mount object already exists
|
||||
at the specified path in the main application's router.
|
||||
"""
|
||||
is_mounted = False
|
||||
|
||||
for route in app.router.routes:
|
||||
|
||||
if isinstance(route, Mount):
|
||||
if route.path == path:
|
||||
is_mounted = True
|
||||
break
|
||||
|
||||
if not is_mounted:
|
||||
app.mount(path, app=sub_app)
|
||||
|
||||
Reference in New Issue
Block a user