From 063a89f143a47213548c79ef4790421d163035ca Mon Sep 17 00:00:00 2001 From: Kodjo Sossouvi Date: Fri, 24 Oct 2025 22:58:48 +0200 Subject: [PATCH] I can use commands --- src/myfasthtml/core/commands.py | 19 ++++- src/myfasthtml/core/testclient.py | 129 ++++++++++++++++++++++++++++- src/myfasthtml/core/utils.py | 20 ++++- tests/test_integration_commands.py | 43 ++++++++-- tests/test_testable_element.py | 69 +++++++++++++++ 5 files changed, 263 insertions(+), 17 deletions(-) create mode 100644 tests/test_testable_element.py diff --git a/src/myfasthtml/core/commands.py b/src/myfasthtml/core/commands.py index 68e3042..79254dc 100644 --- a/src/myfasthtml/core/commands.py +++ b/src/myfasthtml/core/commands.py @@ -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) diff --git a/src/myfasthtml/core/testclient.py b/src/myfasthtml/core/testclient.py index 565ee5c..28696fc 100644 --- a/src/myfasthtml/core/testclient.py +++ b/src/myfasthtml/core/testclient.py @@ -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." diff --git a/src/myfasthtml/core/utils.py b/src/myfasthtml/core/utils.py index 274a769..e2ef70f 100644 --- a/src/myfasthtml/core/utils.py +++ b/src/myfasthtml/core/utils.py @@ -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) diff --git a/tests/test_integration_commands.py b/tests/test_integration_commands.py index cefde9f..0ab6176 100644 --- a/tests/test_integration_commands.py +++ b/tests/test_integration_commands.py @@ -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") diff --git a/tests/test_testable_element.py b/tests/test_testable_element.py new file mode 100644 index 0000000..1878f02 --- /dev/null +++ b/tests/test_testable_element.py @@ -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 == '
hello world
' + + +def test_i_can_create_testable_element_from_str(): + ft = '
hello world
' + testable_element = TestableElement(None, ft) + + assert testable_element.ft == MyFT('div', {'id': 'test'}) + assert testable_element.html_fragment == '
hello world
' + + +def test_i_can_create_testable_element_from_beautifulsoup_element(): + ft = '
hello world
' + 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 == '
hello world
' + + +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
element has no HTMX verb attribute (e.g., hx_get, hx_post) to define a URL."