471 lines
16 KiB
Python
471 lines
16 KiB
Python
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"
|