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,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."
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
69
tests/test_testable_element.py
Normal file
69
tests/test_testable_element.py
Normal 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."
|
||||
Reference in New Issue
Block a user