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

@@ -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)

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,16 +34,99 @@ 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,8 +149,12 @@ 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):
"""
Open a page and store its content for subsequent assertions.
@@ -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."

View File

@@ -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)

View File

@@ -1,26 +1,53 @@
import pytest
from fasthtml.fastapp import fast_app
from myfasthtml.controls.button import mk_button
from myfasthtml.core.commands import Command
from myfasthtml.core.testclient import MyTestClient
from myfasthtml.core.commands import Command, CommandsManager
from myfasthtml.core.testclient import MyTestClient, TestableElement
def new_value(value):
return value
command = Command('test', 'TestingCommand', new_value, "this is my new value")
def test_i_can_trigger_a_command():
@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")
b = user.find_element("button")
b.click()
user.find_element("button").click()
user.should_see("this is my new value")

View File

@@ -0,0 +1,69 @@
import pytest
from fasthtml.components import Div
from fasthtml.fastapp import fast_app
from myfasthtml.core.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.html_fragment == '<div id="test">hello world</div>'
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.html_fragment == '<div id="test">hello world</div>'
def test_i_can_create_testable_element_from_beautifulsoup_element():
ft = '<div id="test">hello world</div>'
from bs4 import BeautifulSoup
tag = BeautifulSoup(ft, 'html.parser').div
testable_element = TestableElement(None, tag)
assert testable_element.ft == MyFT('div', {'id': 'test'})
assert testable_element.html_fragment == '<div id="test">hello world</div>'
def test_i_cannot_create_testable_element_from_other_type():
with pytest.raises(ValueError) as exc_info:
TestableElement(None, 123)
assert str(exc_info.value) == "Invalid source '123' for TestableElement."
def test_i_can_click():
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
ft = Div(
hx_post="/search",
hx_target="#results",
hx_swap="innerHTML",
hx_vals='{"attr": "attr_value"}'
)
testable_element = TestableElement(client, ft)
@rt('/search')
def post(hx_target: str, hx_swap: str, attr: str): # hx_post is used to the Verb. It's not a parameter
return f"received {hx_target=}, {hx_swap=}, {attr=}"
testable_element.click()
assert client.get_content() == "received hx_target='#results', hx_swap='innerHTML', attr='attr_value'"
def test_i_cannot_test_when_not_clickable():
test_app, rt = fast_app(default_hdrs=False)
client = MyTestClient(test_app)
ft = Div("hello world")
testable_element = TestableElement(client, ft)
with pytest.raises(ValueError) as exc_info:
testable_element.click()
assert str(exc_info.value) == "The <div> element has no HTMX verb attribute (e.g., hx_get, hx_post) to define a URL."