from dataclasses import dataclass from typing import Any import pytest from fasthtml.components import Button, Div from myutils.observable import make_observable, bind from myfasthtml.core.commands import Command, CommandsManager, LambdaCommand, BoundCommand from myfasthtml.core.constants import ROUTE_ROOT, Routes from myfasthtml.core.instances import BaseInstance from myfasthtml.test.matcher import matches @dataclass class Data: value: Any def callback(): return "Hello World" @pytest.fixture(autouse=True) def reset_command_manager(): CommandsManager.reset() @pytest.fixture def session(): """Create a test session.""" return {"user_info": {"id": "test-user-123"}} @pytest.fixture def owner(session): """Create a BaseInstance owner for testing bind_command.""" res = BaseInstance(parent=None, session=session, _id="test-owner") res._bound_commands.clear() return res class TestCommandDefault: def test_i_can_create_a_command_with_no_params(self): command = Command('test', 'Command description', None, callback) assert command.id is not None assert command.name == 'test' assert command.description == 'Command description' assert command.execute() == "Hello World" def test_commands_are_registered(self): command = Command('test', 'Command description', None, callback) assert CommandsManager.commands.get(str(command.id)) is command def test_commands_with_the_same_key_share_the_same_id(self): command1 = Command('test', 'Command description', None, None, key="test_key") command2 = Command('test', 'Command description', None, None, key="test_key") assert command1.id is command2.id class TestCommandBind: def test_i_can_bind_a_command_to_an_element(self): command = Command('test', 'Command description', None, callback) elt = Button() updated = command.bind_ft(elt) expected = Button(hx_post=f"{ROUTE_ROOT}{Routes.Commands}") assert matches(updated, expected) def test_i_can_suppress_swapping_with_target_attr(self): command = Command('test', 'Command description', None, callback).htmx(target=None) elt = Button() updated = command.bind_ft(elt) expected = Button(hx_post=f"{ROUTE_ROOT}{Routes.Commands}", hx_swap="none") assert matches(updated, expected) def test_i_can_bind_a_command_to_an_observable(self): data = Data("hello") def on_data_change(old, new): return old, new def another_callback(): data.value = "new value" return "another callback result" make_observable(data) bind(data, "value", on_data_change) command = Command('test', 'Command description', None, another_callback).bind(data) res = command.execute() assert res == ["another callback result", "hello", "new value"] def test_i_can_bind_a_command_to_an_observable_2(self): data = Data("hello") def on_data_change(old, new): return old, new def another_callback(): data.value = "new value" return ["another 1", "another 2"] make_observable(data) bind(data, "value", on_data_change) command = Command('test', 'Command description', None, another_callback).bind(data) res = command.execute() assert res == ["another 1", "another 2", "hello", "new value"] def test_by_default_swap_is_set_to_outer_html(self): command = Command('test', 'Command description', None, callback) elt = Button() updated = command.bind_ft(elt) expected = Button(hx_post=f"{ROUTE_ROOT}{Routes.Commands}", hx_swap="outerHTML") assert matches(updated, expected) @pytest.mark.parametrize("return_values", [ [Div(), Div(id="id1"), "hello", Div(id="id2")], # list (Div(), Div(id="id1"), "hello", Div(id="id2")) # tuple ]) def test_swap_oob_is_automatically_set_when_multiple_elements_are_returned(self, return_values): """Test that hx-swap-oob is automatically set, but not for the first.""" def another_callback(): return return_values command = Command('test', 'Command description', None, another_callback) res = command.execute() assert "hx_swap_oob" not in res[0].attrs assert res[1].attrs["hx-swap-oob"] == "true" assert res[3].attrs["hx-swap-oob"] == "true" def test_i_can_send_parameters(self): command = Command('test', 'Command description', None, None, kwargs={"param": "value"}) # callback is not important elt = Button() updated = command.bind_ft(elt) hx_vals = updated.attrs["hx-vals"] assert 'param' in hx_vals assert hx_vals['param'] == 'value' class TestCommandExecute: def test_i_can_create_a_command_with_no_params(self): command = Command('test', 'Command description', None, callback) assert command.id is not None assert command.name == 'test' assert command.description == 'Command description' assert command.execute() == "Hello World" def test_i_can_execute_a_command_with_closed_parameter(self): """The parameter is given when the command is created.""" def callback_with_param(param): return f"Hello {param}" command = Command('test', 'Command description', None, callback_with_param, args=["world"]) assert command.execute() == "Hello world" def test_i_can_execute_a_command_with_open_parameter(self): """The parameter is given by the browser, when the command is executed.""" def callback_with_param(name): return f"Hello {name}" command = Command('test', 'Command description', None, callback_with_param) assert command.execute(client_response={"name": "world"}) == "Hello world" def test_i_can_convert_arg_in_execute(self): """The parameter is given by the browser, when the command is executed.""" def callback_with_param(number: int): assert isinstance(number, int) command = Command('test', 'Command description', None, callback_with_param) command.execute(client_response={"number": "10"}) def test_swap_oob_is_added_when_multiple_elements_are_returned(self): """Test that hx-swap-oob is automatically set, but not for the first.""" def another_callback(): return Div(id="first"), Div(id="second"), "hello", Div(id="third") command = Command('test', 'Command description', None, another_callback) res = command.execute() assert "hx-swap-oob" not in res[0].attrs assert res[1].attrs["hx-swap-oob"] == "true" assert res[3].attrs["hx-swap-oob"] == "true" def test_swap_oob_is_not_added_when_there_no_id(self): """Test that hx-swap-oob is automatically set, but not for the first.""" def another_callback(): return Div(id="first"), Div(), "hello", Div() command = Command('test', 'Command description', None, another_callback) res = command.execute() assert "hx-swap-oob" not in res[0].attrs assert "hx-swap-oob" not in res[1].attrs assert "hx-swap-oob" not in res[3].attrs @pytest.mark.parametrize("when", ["before", "after"]) def test_i_can_execute_bound_command_with_when(self, owner, when): """Test that bound commands execute before or after main callback based on when parameter.""" execution_order = [] def main_callback(): execution_order.append("main") return Div(id="main") def bound_callback(): execution_order.append(when) return Div(id=when) main_command = Command('main', 'Main command', owner, main_callback) bound_command = Command('bound', 'Bound command', owner, bound_callback) owner.bind_command(main_command, bound_command, when=when) res = main_command.execute() if when == "before": assert execution_order == ["before", "main"] else: assert execution_order == ["main", "after"] assert isinstance(res, list) def test_i_can_execute_multiple_bound_commands_in_correct_order(self, owner): """Test that multiple bound commands execute in correct order: before1, before2, main, after1, after2.""" execution_order = [] def main_callback(): execution_order.append("main") return Div(id="main") def before1_callback(): execution_order.append("before1") return Div(id="before1") def before2_callback(): execution_order.append("before2") return Div(id="before2") def after1_callback(): execution_order.append("after1") return Div(id="after1") def after2_callback(): execution_order.append("after2") return Div(id="after2") main_command = Command('main', 'Main command', owner, main_callback) before1_command = Command('before1', 'Before 1 command', owner, before1_callback) before2_command = Command('before2', 'Before 2 command', owner, before2_callback) after1_command = Command('after1', 'After 1 command', owner, after1_callback) after2_command = Command('after2', 'After 2 command', owner, after2_callback) owner.bind_command(main_command, before1_command, when="before") owner.bind_command(main_command, before2_command, when="before") owner.bind_command(main_command, after1_command, when="after") owner.bind_command(main_command, after2_command, when="after") res = main_command.execute() assert execution_order == ["before1", "before2", "main", "after1", "after2"] assert isinstance(res, list) def test_main_callback_result_is_first_in_all_ret(self, owner): """Test that main callback result is always all_ret[0] for HTMX target.""" def main_callback(): return Div(id="main", cls="main-result") def before_callback(): return Div(id="before") def after_callback(): return Div(id="after") main_command = Command('main', 'Main command', owner, main_callback) before_command = Command('before', 'Before command', owner, before_callback) after_command = Command('after', 'After command', owner, after_callback) owner.bind_command(main_command, before_command, when="before") owner.bind_command(main_command, after_command, when="after") res = main_command.execute() assert isinstance(res, list) assert res[0].attrs["id"] == "main" assert res[0].attrs.get("class") == "main-result" def test_hx_swap_oob_is_applied_to_bound_commands_results(self, owner): """Test that hx-swap-oob is applied to bound commands results but not to main result.""" def main_callback(): return Div(id="main") def before_callback(): return Div(id="before") def after_callback(): return Div(id="after") main_command = Command('main', 'Main command', owner, main_callback) before_command = Command('before', 'Before command', owner, before_callback) after_command = Command('after', 'After command', owner, after_callback) owner.bind_command(main_command, before_command, when="before") owner.bind_command(main_command, after_command, when="after") res = main_command.execute() assert isinstance(res, list) assert len(res) == 3 # Main result should NOT have hx-swap-oob assert "hx-swap-oob" not in res[0].attrs # Bound commands results should have hx-swap-oob assert res[1].attrs["hx-swap-oob"] == "true" assert res[2].attrs["hx-swap-oob"] == "true" def test_bound_commands_without_return_do_not_affect_main_result(self, owner): """Test that bound commands without return value do not affect main result.""" def main_callback(): return Div(id="main") def before_callback(): # No return value pass def after_callback(): # No return value pass main_command = Command('main', 'Main command', owner, main_callback) before_command = Command('before', 'Before command', owner, before_callback) after_command = Command('after', 'After command', owner, after_callback) owner.bind_command(main_command, before_command, when="before") owner.bind_command(main_command, after_command, when="after") res = main_command.execute() # When bound commands return None, they are still included in the result list # but the main callback result should still be first assert isinstance(res, list) assert res[0].attrs["id"] == "main" def test_i_can_combine_bound_commands_with_observable_bindings(self, owner): """Test that bound commands work correctly with observable bindings.""" data = Data("initial") execution_order = [] def on_data_change(old, new): execution_order.append("observable") return Div(id="observable", cls="data-changed") def main_callback(): execution_order.append("main") data.value = "modified" return Div(id="main") def before_callback(): execution_order.append("before") return Div(id="before") make_observable(data) bind(data, "value", on_data_change) main_command = Command('main', 'Main command', owner, main_callback).bind(data) before_command = Command('before', 'Before command', owner, before_callback) owner.bind_command(main_command, before_command, when="before") res = main_command.execute() # Execution order: before -> main -> observable change assert execution_order == ["before", "main", "observable"] assert isinstance(res, list) class TestLambaCommand: def test_i_can_create_a_command_from_lambda(self): command = LambdaCommand(None, lambda: "Hello World") assert command.execute() == "Hello World" def test_by_default_target_is_none(self): command = LambdaCommand(None, lambda: "Hello World") assert command.get_htmx_params()["hx-swap"] == "none" class TestBoundCommand: """Tests for BoundCommand dataclass.""" @pytest.mark.parametrize("when_param, expected_when", [ (None, "after"), # default ("before", "before"), # explicit before ("after", "after"), # explicit after ]) def test_i_can_create_bound_command(self, when_param, expected_when): """Test that BoundCommand can be created with different when values.""" command = Command('test', 'Command description', None, callback) if when_param is None: bound = BoundCommand(command=command) else: bound = BoundCommand(command=command, when=when_param) assert bound.command is command assert bound.when == expected_when class TestCommandBindCommand: """Tests for binding commands to other commands with when parameter.""" @pytest.mark.parametrize("when_param, expected_when", [ (None, "after"), # default ("before", "before"), # explicit before ("after", "after"), # explicit after ]) def test_i_can_bind_command_with_when(self, owner, when_param, expected_when): """Test that a command can be bound to another command with when parameter.""" main_command = Command('main', 'Main command', owner, callback) bound_command = Command('bound', 'Bound command', owner, callback) if when_param is None: owner.bind_command(main_command, bound_command) else: owner.bind_command(main_command, bound_command, when=when_param) bound_commands = owner.get_bound_commands('main') assert len(bound_commands) == 1 assert bound_commands[0].command is bound_command assert bound_commands[0].when == expected_when def test_i_can_bind_multiple_commands_with_different_when(self, owner): """Test that multiple commands can be bound with different when values.""" main_command = Command('main', 'Main command', owner, callback) before_command = Command('before', 'Before command', owner, callback) after_command = Command('after', 'After command', owner, callback) owner.bind_command(main_command, before_command, when="before") owner.bind_command(main_command, after_command, when="after") bound_commands = owner.get_bound_commands('main') assert len(bound_commands) == 2 assert bound_commands[0].command is before_command assert bound_commands[0].when == "before" assert bound_commands[1].command is after_command assert bound_commands[1].when == "after"