Added first controls

This commit is contained in:
2025-11-26 20:53:12 +01:00
parent 459c89bae2
commit ce5328fe34
68 changed files with 37849 additions and 87048 deletions

View File

@@ -0,0 +1,23 @@
import pytest
from myfasthtml.core.instances import SingleInstance
@pytest.fixture(scope="session")
def session():
return {
"user_info": {
'email': 'test@myfasthtml.com',
'username': 'test_user',
'roles': ['admin'],
'user_settings': {},
'id': 'test_user_id',
'created_at': '2025-11-10T15:52:59.006213',
'updated_at': '2025-11-10T15:52:59.006213'
}
}
@pytest.fixture(scope="session")
def root_instance(session):
return SingleInstance(None, session, "TestRoot")

View File

@@ -0,0 +1,126 @@
import shutil
import pytest
from fasthtml.components import *
from fasthtml.xtend import Script
from myfasthtml.controls.TabsManager import TabsManager
from myfasthtml.core.instances import InstancesManager
from myfasthtml.test.matcher import matches, NoChildren
from .conftest import session
@pytest.fixture()
def tabs_manager(root_instance):
shutil.rmtree(".myFastHtmlDb", ignore_errors=True)
yield TabsManager(root_instance)
InstancesManager.reset()
shutil.rmtree(".myFastHtmlDb", ignore_errors=True)
class TestTabsManagerBehaviour:
def test_tabs_manager_is_registered(self, session, tabs_manager):
from_instance_manager = InstancesManager.get(session, tabs_manager.get_id())
assert from_instance_manager == tabs_manager
def test_i_can_add_tab(self, tabs_manager):
tab_id = tabs_manager.add_tab("Tab1", Div("Content 1"))
assert tab_id is not None
assert tab_id in tabs_manager.get_state().tabs
assert tabs_manager.get_state().tabs[tab_id]["label"] == "Tab1"
assert tabs_manager.get_state().tabs[tab_id]["id"] == tab_id
assert tabs_manager.get_state().tabs[tab_id]["component_type"] is None # Div is not BaseInstance
assert tabs_manager.get_state().tabs[tab_id]["component_id"] is None # Div is not BaseInstance
assert tabs_manager.get_state().tabs_order == [tab_id]
assert tabs_manager.get_state().active_tab == tab_id
def test_i_can_add_multiple_tabs(self, tabs_manager):
tab_id1 = tabs_manager.add_tab("Users", Div("Content 1"))
tab_id2 = tabs_manager.add_tab("User2", Div("Content 2"))
assert len(tabs_manager.get_state().tabs) == 2
assert tabs_manager.get_state().tabs_order == [tab_id1, tab_id2]
assert tabs_manager.get_state().active_tab == tab_id2
def test_i_can_show_tab(self, tabs_manager):
tab_id1 = tabs_manager.add_tab("Tab1", Div("Content 1"))
tab_id2 = tabs_manager.add_tab("Tab2", Div("Content 2"))
assert tabs_manager.get_state().active_tab == tab_id2 # last crated tab is active
tabs_manager.show_tab(tab_id1)
assert tabs_manager.get_state().active_tab == tab_id1
def test_i_can_close_tab(self, tabs_manager):
tab_id1 = tabs_manager.add_tab("Tab1", Div("Content 1"))
tab_id2 = tabs_manager.add_tab("Tab2", Div("Content 2"))
tab_id3 = tabs_manager.add_tab("Tab3", Div("Content 3"))
tabs_manager.close_tab(tab_id2)
assert len(tabs_manager.get_state().tabs) == 2
assert [tab_id for tab_id in tabs_manager.get_state().tabs] == [tab_id1, tab_id3]
assert tabs_manager.get_state().tabs_order == [tab_id1, tab_id3]
assert tabs_manager.get_state().active_tab == tab_id3 # last tab stays active
def test_i_still_have_an_active_tab_after_close(self, tabs_manager):
tab_id1 = tabs_manager.add_tab("Tab1", Div("Content 1"))
tab_id2 = tabs_manager.add_tab("Tab2", Div("Content 2"))
tab_id3 = tabs_manager.add_tab("Tab3", Div("Content 3"))
tabs_manager.close_tab(tab_id3) # close the currently active tab
assert tabs_manager.get_state().active_tab == tab_id1 # default to the first tab
class TestTabsManagerRender:
def test_i_can_render_when_no_tabs(self, tabs_manager):
res = tabs_manager.render()
expected = Div(
Div(
Div(id=f"{tabs_manager.get_id()}-controller"),
Script(f'updateTabs("{tabs_manager.get_id()}-controller");'),
),
Div(
Div(NoChildren(), id=f"{tabs_manager.get_id()}-header"),
id=f"{tabs_manager.get_id()}-header-wrapper"
),
Div(
Div(id=f"{tabs_manager.get_id()}-None-content"),
id=f"{tabs_manager.get_id()}-content-wrapper"
),
id=tabs_manager.get_id(),
)
assert matches(res, expected)
def test_i_can_render_when_multiple_tabs(self, tabs_manager):
tabs_manager.add_tab("Users1", Div("Content 1"))
tabs_manager.add_tab("Users2", Div("Content 2"))
tabs_manager.add_tab("Users3", Div("Content 3"))
res = tabs_manager.render()
expected = Div(
Div(
Div(id=f"{tabs_manager.get_id()}-controller"),
Script(f'updateTabs("{tabs_manager.get_id()}-controller");'),
),
Div(
Div(
Div(), # tab_button
Div(), # tab_button
Div(), # tab_button
id=f"{tabs_manager.get_id()}-header"
),
id=f"{tabs_manager.get_id()}-header-wrapper"
),
Div(
Div("Content 3"), # active tab content
# Lasy loading for the other contents
id=f"{tabs_manager.get_id()}-content-wrapper"
),
id=tabs_manager.get_id(),
)
assert matches(res, expected)

View File

@@ -2,10 +2,10 @@ from dataclasses import dataclass
from typing import Any
import pytest
from fasthtml.components import Button
from fasthtml.components import Button, Div
from myutils.observable import make_observable, bind
from myfasthtml.core.commands import Command, CommandsManager
from myfasthtml.core.commands import Command, CommandsManager, LambdaCommand
from myfasthtml.core.constants import ROUTE_ROOT, Routes
from myfasthtml.test.matcher import matches
@@ -24,72 +24,173 @@ def reset_command_manager():
CommandsManager.reset()
def test_i_can_create_a_command_with_no_params():
command = Command('test', 'Command description', callback)
assert command.id is not None
assert command.name == 'test'
assert command.description == 'Command description'
assert command.execute() == "Hello World"
class TestCommandDefault:
def test_i_can_create_a_command_with_no_params(self):
command = Command('test', 'Command description', callback)
assert command.id is not None
assert command.name == 'test'
assert command.description == 'Command description'
assert command.execute() == "Hello World"
def test_command_are_registered(self):
command = Command('test', 'Command description', callback)
assert CommandsManager.commands.get(str(command.id)) is command
def test_command_are_registered():
command = Command('test', 'Command description', callback)
assert CommandsManager.commands.get(str(command.id)) is command
class TestCommandBind:
def test_i_can_bind_a_command_to_an_element(self):
command = Command('test', 'Command description', 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', 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', 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', 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', 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', 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_bind_a_command_to_an_element():
command = Command('test', 'Command description', callback)
elt = Button()
updated = command.bind_ft(elt)
class TestCommandExecute:
expected = Button(hx_post=f"{ROUTE_ROOT}{Routes.Commands}")
def test_i_can_create_a_command_with_no_params(self):
command = Command('test', 'Command description', callback)
assert command.id is not None
assert command.name == 'test'
assert command.description == 'Command description'
assert command.execute() == "Hello World"
assert matches(updated, expected)
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', callback_with_param, "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', 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', 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', 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', 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
def test_i_can_suppress_swapping_with_target_attr():
command = Command('test', 'Command description', callback).htmx(target=None)
elt = Button()
updated = command.bind_ft(elt)
class TestLambaCommand:
expected = Button(hx_post=f"{ROUTE_ROOT}{Routes.Commands}", hx_swap="none")
def test_i_can_create_a_command_from_lambda(self):
command = LambdaCommand(lambda resp: "Hello World")
assert command.execute() == "Hello World"
assert matches(updated, expected)
def test_i_can_bind_a_command_to_an_observable():
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', 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():
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', another_callback).bind(data)
res = command.execute()
assert res == ["another 1", "another 2", ("hello", "new value")]
def test_by_default_target_is_none(self):
command = LambdaCommand(lambda resp: "Hello World")
assert command.get_htmx_params()["hx-swap"] == "none"

View File

@@ -0,0 +1,264 @@
import shutil
from dataclasses import dataclass
import pytest
from myfasthtml.core.dbmanager import DbManager, DbObject
from myfasthtml.core.instances import SingleInstance, BaseInstance
@pytest.fixture(scope="session")
def session():
return {
"user_info": {
"id": "test_tenant_id",
"email": "test@email.com",
"username": "test user",
"role": [],
}
}
@pytest.fixture
def parent(session):
return SingleInstance(session=session, _id="test_parent_id")
@pytest.fixture
def db_manager(parent):
shutil.rmtree("TestDb", ignore_errors=True)
db_manager_instance = DbManager(parent, root="TestDb", auto_register=False)
yield db_manager_instance
shutil.rmtree("TestDb", ignore_errors=True)
def simplify(res: dict) -> dict:
return {k: v for k, v in res.items() if not k.startswith("_")}
def test_i_can_init(parent, db_manager):
class DummyObject(DbObject):
def __init__(self, owner: BaseInstance):
super().__init__(owner, "DummyObject", db_manager)
with self.initializing():
self.value: str = "hello"
self.number: int = 42
self.none_value: None = None
dummy = DummyObject(parent)
props = dummy._get_properties()
in_db = db_manager.load("DummyObject")
history = db_manager.db.history(db_manager.get_tenant(), "DummyObject")
assert simplify(in_db) == {"value": "hello", "number": 42, "none_value": None}
assert len(history) == 1
def test_i_can_init_from_dataclass(parent, db_manager):
@dataclass
class DummyObject(DbObject):
def __init__(self, owner: BaseInstance):
super().__init__(owner, "DummyObject", db_manager)
value: str = "hello"
number: int = 42
none_value: None = None
DummyObject(parent)
in_db = db_manager.load("DummyObject")
history = db_manager.db.history(db_manager.get_tenant(), "DummyObject")
assert simplify(in_db) == {"value": "hello", "number": 42, "none_value": None}
assert len(history) == 1
def test_i_can_init_from_db_with(parent, db_manager):
class DummyObject(DbObject):
def __init__(self, owner: BaseInstance):
super().__init__(owner, "DummyObject", db_manager)
with self.initializing():
self.value: str = "hello"
self.number: int = 42
# insert other values in db
db_manager.save("DummyObject", {"value": "other_value", "number": 34})
dummy = DummyObject(parent)
assert dummy.value == "other_value"
assert dummy.number == 34
def test_i_can_init_from_db_with_dataclass(parent, db_manager):
@dataclass
class DummyObject(DbObject):
def __init__(self, owner: BaseInstance):
super().__init__(owner, "DummyObject", db_manager)
value: str = "hello"
number: int = 42
# insert other values in db
db_manager.save("DummyObject", {"value": "other_value", "number": 34})
dummy = DummyObject(parent)
assert dummy.value == "other_value"
assert dummy.number == 34
def test_i_do_not_save_when_prefixed_by_underscore_or_ns(parent, db_manager):
class DummyObject(DbObject):
def __init__(self, owner: BaseInstance):
super().__init__(owner, "DummyObject", db_manager)
with self.initializing():
self.to_save: str = "value"
self._not_to_save: str = "value"
self.ns_not_to_save: str = "value"
to_save: str = "value"
_not_to_save: str = "value"
ns_not_to_save: str = "value"
dummy = DummyObject(parent)
dummy.to_save = "other_value"
dummy.ns_not_to_save = "other_value"
dummy._not_to_save = "other_value"
in_db = db_manager.load("DummyObject")
assert in_db["to_save"] == "other_value"
assert "_not_to_save" not in in_db
assert "ns_not_to_save" not in in_db
def test_i_do_not_save_when_prefixed_by_underscore_or_ns_with_dataclass(parent, db_manager):
@dataclass
class DummyObject(DbObject):
def __init__(self, owner: BaseInstance):
super().__init__(owner, "DummyObject", db_manager)
to_save: str = "value"
_not_to_save: str = "value"
ns_not_to_save: str = "value"
dummy = DummyObject(parent)
dummy.to_save = "other_value"
dummy.ns_not_to_save = "other_value"
dummy._not_to_save = "other_value"
in_db = db_manager.load("DummyObject")
assert in_db["to_save"] == "other_value"
assert "_not_to_save" not in in_db
assert "ns_not_to_save" not in in_db
def test_db_is_updated_when_attribute_is_modified(parent, db_manager):
@dataclass
class DummyObject(DbObject):
def __init__(self, owner: BaseInstance):
super().__init__(owner, "DummyObject", db_manager)
value: str = "hello"
number: int = 42
dummy = DummyObject(parent)
dummy.value = "other_value"
assert simplify(db_manager.load("DummyObject")) == {"value": "other_value", "number": 42}
def test_i_do_not_save_in_db_when_value_is_the_same(parent, db_manager):
@dataclass
class DummyObject(DbObject):
def __init__(self, owner: BaseInstance):
super().__init__(owner, "DummyObject", db_manager)
value: str = "hello"
number: int = 42
dummy = DummyObject(parent)
dummy.value = "other_value"
in_db_1 = db_manager.load("DummyObject")
dummy.value = "other_value"
in_db_2 = db_manager.load("DummyObject")
assert in_db_1["__parent__"] == in_db_2["__parent__"]
def test_i_can_update(parent, db_manager):
@dataclass
class DummyObject(DbObject):
def __init__(self, owner: BaseInstance):
super().__init__(owner, "DummyObject", db_manager)
value: str = "hello"
number: int = 42
dummy = DummyObject(parent)
clone = dummy.copy()
clone.number = 34
clone.value = "other_value"
clone.other_attr = "some_value"
dummy.update(clone)
assert simplify(db_manager.load("DummyObject")) == {"value": "other_value", "number": 34}
def test_forbidden_attributes_are_not_the_copy(parent, db_manager):
class DummyObject(DbObject):
def __init__(self, owner: BaseInstance):
super().__init__(owner, "DummyObject", db_manager)
with self.initializing():
self.value: str = "hello"
self.number: int = 42
self.none_value: None = None
dummy = DummyObject(parent)
clone = dummy.copy()
for k in DbObject._forbidden_attrs:
assert not hasattr(clone, k), f"Clone should not have forbidden attribute '{k}'"
def test_forbidden_attributes_are_not_the_copy_for_dataclass(parent, db_manager):
@dataclass
class DummyObject(DbObject):
def __init__(self, owner: BaseInstance):
super().__init__(owner, "DummyObject", db_manager)
value: str = "hello"
number: int = 42
none_value: None = None
dummy = DummyObject(parent)
clone = dummy.copy()
for k in DbObject._forbidden_attrs:
assert not hasattr(clone, k), f"Clone should not have forbidden attribute '{k}'"
def test_i_cannot_update_a_forbidden_attribute(parent, db_manager):
@dataclass
class DummyObject(DbObject):
def __init__(self, owner: BaseInstance):
super().__init__(owner, "DummyObject", db_manager)
value: str = "hello"
number: int = 42
none_value: None = None
dummy = DummyObject(parent)
dummy.update(_owner="other_value")
assert dummy._owner is parent

View File

@@ -0,0 +1,399 @@
import pytest
from myfasthtml.core.instances import (
BaseInstance,
SingleInstance,
MultipleInstance,
InstancesManager,
DuplicateInstanceError,
special_session,
Ids,
RootInstance
)
@pytest.fixture(autouse=True)
def reset_instances():
"""Reset instances before each test to ensure isolation."""
InstancesManager.instances.clear()
yield
InstancesManager.instances.clear()
@pytest.fixture
def session():
"""Create a test session."""
return {"user_info": {"id": "test-user-123"}}
@pytest.fixture
def another_session():
"""Create another test session."""
return {"user_info": {"id": "test-user-456"}}
@pytest.fixture
def root_instance(session):
"""Create a root instance for testing."""
return SingleInstance(parent=None, session=session, _id="test-root")
# Example subclasses for testing
class SubSingleInstance(SingleInstance):
"""Example subclass of SingleInstance with simplified signature."""
def __init__(self, parent):
super().__init__(parent=parent)
class SubMultipleInstance(MultipleInstance):
"""Example subclass of MultipleInstance with custom parameter."""
def __init__(self, parent, _id=None, custom_param=None):
super().__init__(parent=parent, _id=_id)
self.custom_param = custom_param
class TestBaseInstance:
def test_i_can_create_a_base_instance_with_positional_args(self, session, root_instance):
"""Test that a BaseInstance can be created with positional arguments."""
instance = BaseInstance(root_instance, session, "test_id")
assert instance is not None
assert instance.get_id() == "test_id"
assert instance.get_session() == session
assert instance.get_parent() == root_instance
def test_i_can_create_a_base_instance_with_kwargs(self, session, root_instance):
"""Test that a BaseInstance can be created with keyword arguments."""
instance = BaseInstance(parent=root_instance, session=session, _id="test_id")
assert instance is not None
assert instance.get_id() == "test_id"
assert instance.get_session() == session
assert instance.get_parent() == root_instance
def test_i_can_create_a_base_instance_with_mixed_args(self, session, root_instance):
"""Test that a BaseInstance can be created with mixed positional and keyword arguments."""
instance = BaseInstance(root_instance, session=session, _id="test_id")
assert instance is not None
assert instance.get_id() == "test_id"
assert instance.get_session() == session
assert instance.get_parent() == root_instance
def test_i_can_retrieve_the_same_instance_when_using_same_session_and_id(self, session, root_instance):
"""Test that creating an instance with same session and id returns the existing instance."""
instance1 = BaseInstance(root_instance, session, "same_id")
instance2 = BaseInstance(root_instance, session, "same_id")
assert instance1 is instance2
def test_i_can_control_instances_registration(self, session, root_instance):
"""Test that auto_register=False prevents automatic registration."""
BaseInstance(parent=root_instance, session=session, _id="test_id", auto_register=False)
session_id = InstancesManager.get_session_id(session)
key = (session_id, "test_id")
assert key not in InstancesManager.instances
def test_i_can_have_different_instances_for_different_sessions(self, session, another_session, root_instance):
"""Test that different sessions can have instances with the same id."""
root_instance2 = SingleInstance(parent=None, session=another_session, _id="test-root")
instance1 = BaseInstance(root_instance, session, "same_id")
instance2 = BaseInstance(root_instance2, another_session, "same_id")
assert instance1 is not instance2
assert instance1.get_session() == session
assert instance2.get_session() == another_session
def test_i_can_create_instance_with_parent_only(self, session, root_instance):
"""Test that session can be extracted from parent when not provided."""
instance = BaseInstance(parent=root_instance, _id="test_id")
assert instance.get_session() == root_instance.get_session()
assert instance.get_parent() == root_instance
def test_i_cannot_create_instance_without_parent_or_session(self):
"""Test that creating an instance without parent or session raises TypeError."""
with pytest.raises(TypeError, match="Either session or parent must be provided"):
BaseInstance(None, _id="test_id")
def test_i_can_get_auto_generated_id(self, session, root_instance):
"""Test that if _id is not provided, an ID is auto-generated via compute_id()."""
instance = BaseInstance(parent=root_instance, session=session)
assert instance.get_id() is not None
assert instance.get_id().startswith("mf-base_instance-")
def test_i_can_get_prefix_from_class_name(self, session):
"""Test that get_prefix() returns the correct snake_case prefix."""
prefix = BaseInstance(None, session).get_prefix()
assert prefix == "mf-base_instance"
class TestSingleInstance:
def test_i_can_create_a_single_instance(self, session, root_instance):
"""Test that a SingleInstance can be created."""
instance = SingleInstance(parent=root_instance, session=session)
assert instance is not None
assert instance.get_id() == "mf-single_instance"
assert instance.get_session() == session
assert instance.get_parent() == root_instance
def test_i_can_create_single_instance_with_positional_args(self, session, root_instance):
"""Test that a SingleInstance can be created with positional arguments."""
instance = SingleInstance(root_instance, session, "custom_id")
assert instance is not None
assert instance.get_id() == "custom_id"
assert instance.get_session() == session
assert instance.get_parent() == root_instance
def test_the_same_instance_is_returned(self, session):
"""Test that single instance is cached and returned on subsequent calls."""
instance1 = SingleInstance(parent=None, session=session, _id="unique_id")
instance2 = SingleInstance(parent=None, session=session, _id="unique_id")
assert instance1 is instance2
def test_i_cannot_create_duplicate_single_instance(self, session):
"""Test that creating a duplicate SingleInstance raises DuplicateInstanceError."""
instance = SingleInstance(parent=None, session=session, _id="unique_id")
with pytest.raises(DuplicateInstanceError):
InstancesManager.register(session, instance)
def test_i_can_retrieve_existing_single_instance(self, session):
"""Test that attempting to create an existing SingleInstance returns the same instance."""
instance1 = SingleInstance(parent=None, session=session, _id="same_id")
instance2 = SingleInstance(parent=None, session=session, _id="same_id", auto_register=False)
assert instance1 is instance2
def test_i_can_get_auto_computed_id_for_single_instance(self, session):
"""Test that the default ID equals prefix for SingleInstance."""
instance = SingleInstance(parent=None, session=session)
assert instance.get_id() == "mf-single_instance"
assert instance.get_prefix() == "mf-single_instance"
class TestSingleInstanceSubclass:
def test_i_can_create_subclass_of_single_instance(self, root_instance):
"""Test that a subclass of SingleInstance works correctly."""
instance = SubSingleInstance(root_instance)
assert instance is not None
assert isinstance(instance, SingleInstance)
assert isinstance(instance, SubSingleInstance)
def test_i_can_create_subclass_with_custom_signature(self, root_instance):
"""Test that subclass with simplified signature works correctly."""
instance = SubSingleInstance(root_instance)
assert instance.get_parent() == root_instance
assert instance.get_session() == root_instance.get_session()
assert instance.get_id() == "mf-sub_single_instance"
assert instance.get_prefix() == "mf-sub_single_instance"
def test_i_can_retrieve_subclass_instance_from_cache(self, root_instance):
"""Test that cache works for subclasses."""
instance1 = SubSingleInstance(root_instance)
instance2 = SubSingleInstance(root_instance)
assert instance1 is instance2
class TestMultipleInstance:
def test_i_can_create_multiple_instances_with_same_prefix(self, session, root_instance):
"""Test that multiple MultipleInstance objects can be created with the same prefix."""
instance1 = MultipleInstance(parent=root_instance, session=session)
instance2 = MultipleInstance(parent=root_instance, session=session)
assert instance1 is not instance2
assert instance1.get_id() != instance2.get_id()
assert instance1.get_id().startswith("mf-multiple_instance-")
assert instance2.get_id().startswith("mf-multiple_instance-")
def test_i_can_have_auto_generated_unique_ids(self, session, root_instance):
"""Test that each MultipleInstance receives a unique auto-generated ID."""
instances = [MultipleInstance(parent=root_instance, session=session) for _ in range(5)]
ids = [inst.get_id() for inst in instances]
# All IDs should be unique
assert len(ids) == len(set(ids))
# All IDs should start with the prefix
assert all(id.startswith("mf-multiple_instance-") for id in ids)
def test_i_can_provide_custom_id_to_multiple_instance(self, session, root_instance):
"""Test that a custom _id can be provided to MultipleInstance."""
custom_id = "custom-instance-id"
instance = MultipleInstance(parent=root_instance, session=session, _id=custom_id)
assert instance.get_id() == custom_id
def test_i_can_retrieve_multiple_instance_by_custom_id(self, session, root_instance):
"""Test that a MultipleInstance with custom _id can be retrieved from cache."""
custom_id = "custom-instance-id"
instance1 = MultipleInstance(parent=root_instance, session=session, _id=custom_id)
instance2 = MultipleInstance(parent=root_instance, session=session, _id=custom_id)
assert instance1 is instance2
def test_key_prefixed_by_underscore_uses_the_parent_id_as_prefix(self, root_instance):
"""Test that key prefixed by underscore uses the parent id as prefix."""
instance = MultipleInstance(parent=root_instance, _id="-test_id")
assert instance.get_id() == f"{root_instance.get_id()}-test_id"
def test_no_parent_id_as_prefix_if_parent_is_none(self, session, root_instance):
"""Test that key prefixed by underscore does not use the parent id as prefix if parent is None."""
instance = MultipleInstance(parent=None, session=session, _id="-test_id")
assert instance.get_id() == "-test_id"
class TestMultipleInstanceSubclass:
def test_i_can_create_subclass_of_multiple_instance(self, root_instance):
"""Test that a subclass of MultipleInstance works correctly."""
instance = SubMultipleInstance(root_instance, custom_param="test")
assert instance is not None
assert isinstance(instance, MultipleInstance)
assert isinstance(instance, SubMultipleInstance)
assert instance.custom_param == "test"
def test_i_can_create_multiple_subclass_instances_with_auto_generated_ids(self, root_instance):
"""Test that multiple instances of subclass can be created with unique IDs."""
instance1 = SubMultipleInstance(root_instance, custom_param="first")
instance2 = SubMultipleInstance(root_instance, custom_param="second")
assert instance1 is not instance2
assert instance1.get_id() != instance2.get_id()
assert instance1.get_id().startswith("mf-sub_multiple_instance-")
assert instance2.get_id().startswith("mf-sub_multiple_instance-")
def test_i_can_create_subclass_with_custom_signature(self, root_instance):
"""Test that subclass with custom parameters works correctly."""
instance = SubMultipleInstance(root_instance, custom_param="value")
assert instance.get_parent() == root_instance
assert instance.get_session() == root_instance.get_session()
assert instance.custom_param == "value"
def test_i_can_retrieve_subclass_instance_from_cache(self, root_instance):
"""Test that cache works for subclasses."""
instance1 = SubMultipleInstance(root_instance, custom_param="first")
instance2 = SubMultipleInstance(root_instance, custom_param="second", _id=instance1.get_id())
assert instance1 is instance2
def test_i_cannot_retrieve_subclass_instance_when_type_differs(self, root_instance):
"""Test that cache works for subclasses with custom _id."""
# Need to pass _id explicitly to enable caching
instance1 = SubMultipleInstance(root_instance)
with pytest.raises(TypeError):
MultipleInstance(parent=root_instance, _id=instance1.get_id())
def test_i_can_get_correct_prefix_for_multiple_subclass(self, root_instance):
"""Test that subclass has correct auto-generated prefix."""
prefix = SubMultipleInstance(root_instance).get_prefix()
assert prefix == "mf-sub_multiple_instance"
class TestInstancesManager:
def test_i_can_register_an_instance_manually(self, session, root_instance):
"""Test that an instance can be manually registered."""
instance = BaseInstance(parent=root_instance, session=session, _id="manual_id", auto_register=False)
InstancesManager.register(session, instance)
session_id = InstancesManager.get_session_id(session)
key = (session_id, "manual_id")
assert key in InstancesManager.instances
assert InstancesManager.instances[key] is instance
def test_i_can_get_existing_instance_by_id(self, session, root_instance):
"""Test that an existing instance can be retrieved by ID."""
instance = BaseInstance(parent=root_instance, session=session, _id="get_id")
retrieved = InstancesManager.get(session, "get_id")
assert retrieved is instance
def test_i_cannot_get_nonexistent_instance_without_type(self, session):
"""Test that getting a non-existent instance without type raises KeyError."""
with pytest.raises(KeyError):
InstancesManager.get(session, "nonexistent_id")
def test_i_can_get_session_id_from_valid_session(self, session):
"""Test that session ID is correctly extracted from a valid session."""
session_id = InstancesManager.get_session_id(session)
assert session_id == "test-user-123"
def test_i_can_handle_none_session(self):
"""Test that None session returns a special identifier."""
session_id = InstancesManager.get_session_id(None)
assert session_id == "** NOT LOGGED IN **"
def test_i_can_handle_invalid_session(self):
"""Test that invalid sessions return appropriate identifiers."""
# Session is None
session_id = InstancesManager.get_session_id(None)
assert session_id == "** NOT LOGGED IN **"
# Session without user_info
session_no_user = {}
session_id = InstancesManager.get_session_id(session_no_user)
assert session_id == "** UNKNOWN USER **"
# Session with user_info but no id
session_no_id = {"user_info": {}}
session_id = InstancesManager.get_session_id(session_no_id)
assert session_id == "** INVALID SESSION **"
def test_i_can_reset_all_instances(self, session, root_instance):
"""Test that reset() clears all instances."""
BaseInstance(parent=root_instance, session=session, _id="id1")
BaseInstance(parent=root_instance, session=session, _id="id2")
assert len(InstancesManager.instances) > 0
InstancesManager.reset()
assert len(InstancesManager.instances) == 0
class TestRootInstance:
def test_i_can_create_root_instance_with_positional_args(self):
"""Test that RootInstance can be created with positional arguments."""
root = SingleInstance(None, special_session, Ids.Root)
assert root is not None
assert root.get_id() == Ids.Root
assert root.get_session() == special_session
assert root.get_parent() is None
def test_i_can_access_root_instance(self):
"""Test that RootInstance is created and accessible."""
assert RootInstance is not None
assert RootInstance.get_id() == Ids.Root
assert RootInstance.get_session() == special_session

View File

@@ -0,0 +1,105 @@
from dataclasses import dataclass
from myfasthtml.core.matching_utils import fuzzy_matching, subsequence_matching
class TestFuzzyMatching:
def test_i_can_find_exact_match_with_fuzzy(self):
# Exact match should always pass
choices = ["hello"]
result = fuzzy_matching("hello", choices)
assert len(result) == 1
assert result[0] == "hello"
def test_i_can_find_close_match_with_fuzzy(self):
# "helo.txt" should match "hello.txt" with high similarity
choices = ["hello"]
result = fuzzy_matching("helo", choices, similarity_threshold=0.7)
assert len(result) == 1
assert result[0] == "hello"
def test_i_cannot_find_dissimilar_match_with_fuzzy(self):
# "world.txt" should not match "hello.txt"
choices = ["hello"]
result = fuzzy_matching("world", choices, similarity_threshold=0.7)
assert len(result) == 0
def test_i_can_sort_by_similarity_in_fuzzy(self):
# hello has a higher similarity than helo
choices = [
"hello",
"helo",
]
result = fuzzy_matching("hello", choices, similarity_threshold=0.7)
assert result == ["hello", "helo"]
def test_i_can_find_on_object(self):
@dataclass
class DummyObject:
value: str
id: str
choices = [
DummyObject("helo", "1"),
DummyObject("hello", "2"),
DummyObject("xyz", "3"),
]
result = fuzzy_matching("hello", choices, get_attr=lambda x: x.value)
assert len(result) == 2
assert result == [DummyObject("hello", "2"), DummyObject("helo", "1")]
class TestSubsequenceMatching:
def test_i_can_match_subsequence_simple(self):
# "abg" should match "AlphaBetaGamma"
choices = ["AlphaBetaGamma"]
result = subsequence_matching("abg", choices)
assert len(result) == 1
assert result[0] == "AlphaBetaGamma"
def test_i_can_match_subsequence_simple_case_insensitive(self):
# "abg" should match "alphabetagamma"
choices = ["alphabetagamma"]
result = subsequence_matching("abg", choices)
assert len(result) == 1
assert result[0] == "alphabetagamma"
def test_i_cannot_match_wrong_order_subsequence(self):
# the order is wrong
choices = ["AlphaBetaGamma"]
result = subsequence_matching("gba", choices)
assert len(result) == 0
def test_i_can_match_multiple_documents_subsequence(self):
# "abg" should match both filenames, but "AlphaBetaGamma" has a higher score
choices = [
"AlphaBetaGamma",
"HalleBerryIsGone",
]
result = subsequence_matching("abg", choices)
assert len(result) == 2
assert result[0] == "AlphaBetaGamma"
assert result[1] == "HalleBerryIsGone"
def test_i_cannot_match_unrelated_subsequence(self):
# "xyz" should not match any file
choices = ["AlphaBetaGamma"]
result = subsequence_matching("xyz", choices)
assert len(result) == 0
def test_i_can_match_on_object(self):
@dataclass
class DummyObject:
value: str
id: str
choices = [
DummyObject("HalleBerryIsGone", "1"),
DummyObject("AlphaBetaGamma", "2"),
DummyObject("xyz", "3"),
]
result = subsequence_matching("abg", choices, get_attr=lambda x: x.value)
assert len(result) == 2
assert result == [DummyObject("AlphaBetaGamma", "2"), DummyObject("HalleBerryIsGone", "1")]

View File

@@ -0,0 +1,648 @@
from myfasthtml.core.network_utils import from_nested_dict, from_tree_with_metadata, from_parent_child_list
class TestFromNestedDict:
def test_i_can_convert_single_root_node(self):
"""Test conversion of a single root node without children."""
trees = [{"root": {}}]
nodes, edges = from_nested_dict(trees)
assert len(nodes) == 1
assert nodes[0] == {"id": 1, "label": "root"}
assert len(edges) == 0
def test_i_can_convert_tree_with_one_level_children(self):
"""Test conversion with direct children, verifying edge creation."""
trees = [{"root": {"child1": {}, "child2": {}}}]
nodes, edges = from_nested_dict(trees)
assert len(nodes) == 3
assert nodes[0] == {"id": 1, "label": "root"}
assert nodes[1] == {"id": 2, "label": "child1"}
assert nodes[2] == {"id": 3, "label": "child2"}
assert len(edges) == 2
assert {"from": 1, "to": 2} in edges
assert {"from": 1, "to": 3} in edges
def test_i_can_convert_tree_with_multiple_levels(self):
"""Test recursive conversion with multiple levels of nesting."""
trees = [
{
"root": {
"child1": {
"grandchild1": {},
"grandchild2": {}
},
"child2": {}
}
}
]
nodes, edges = from_nested_dict(trees)
assert len(nodes) == 5
assert len(edges) == 4
# Verify hierarchy
assert {"from": 1, "to": 2} in edges # root -> child1
assert {"from": 1, "to": 5} in edges # root -> child2
assert {"from": 2, "to": 3} in edges # child1 -> grandchild1
assert {"from": 2, "to": 4} in edges # child1 -> grandchild2
def test_i_can_generate_auto_incremented_ids(self):
"""Test that IDs start at 1 and increment correctly."""
trees = [{"a": {"b": {"c": {}}}}]
nodes, edges = from_nested_dict(trees)
ids = [node["id"] for node in nodes]
assert ids == [1, 2, 3]
def test_i_can_use_dict_keys_as_labels(self):
"""Test that dictionary keys become node labels."""
trees = [{"RootNode": {"ChildNode": {}}}]
nodes, edges = from_nested_dict(trees)
assert nodes[0]["label"] == "RootNode"
assert nodes[1]["label"] == "ChildNode"
def test_i_can_convert_empty_list(self):
"""Test that empty list returns empty nodes and edges."""
trees = []
nodes, edges = from_nested_dict(trees)
assert nodes == []
assert edges == []
def test_i_can_convert_multiple_root_nodes(self):
"""Test conversion with multiple independent trees."""
trees = [
{"root1": {"child1": {}}},
{"root2": {"child2": {}}}
]
nodes, edges = from_nested_dict(trees)
assert len(nodes) == 4
assert nodes[0] == {"id": 1, "label": "root1"}
assert nodes[1] == {"id": 2, "label": "child1"}
assert nodes[2] == {"id": 3, "label": "root2"}
assert nodes[3] == {"id": 4, "label": "child2"}
# Verify edges connect within trees, not across
assert len(edges) == 2
assert {"from": 1, "to": 2} in edges
assert {"from": 3, "to": 4} in edges
def test_i_can_maintain_id_sequence_across_multiple_trees(self):
"""Test that ID counter continues across multiple trees."""
trees = [
{"tree1": {}},
{"tree2": {}},
{"tree3": {}}
]
nodes, edges = from_nested_dict(trees)
ids = [node["id"] for node in nodes]
assert ids == [1, 2, 3]
class TestFromTreeWithMetadata:
def test_i_can_convert_single_node_with_metadata(self):
"""Test basic conversion with explicit id and label."""
trees = [{"id": "root", "label": "Root Node"}]
nodes, edges = from_tree_with_metadata(trees)
assert len(nodes) == 1
assert nodes[0] == {"id": "root", "label": "Root Node"}
assert len(edges) == 0
def test_i_can_preserve_string_ids_from_metadata(self):
"""Test that string IDs from the tree are preserved."""
trees = [
{
"id": "root_id",
"label": "Root",
"children": [
{"id": "child_id", "label": "Child"}
]
}
]
nodes, edges = from_tree_with_metadata(trees)
assert nodes[0]["id"] == "root_id"
assert nodes[1]["id"] == "child_id"
assert edges[0] == {"from": "root_id", "to": "child_id"}
def test_i_can_auto_increment_when_id_is_missing(self):
"""Test fallback to auto-increment when ID is not provided."""
trees = [
{
"label": "Root",
"children": [
{"label": "Child1"},
{"id": "child2_id", "label": "Child2"}
]
}
]
nodes, edges = from_tree_with_metadata(trees)
assert nodes[0]["id"] == 1 # auto-incremented
assert nodes[1]["id"] == 2 # auto-incremented
assert nodes[2]["id"] == "child2_id" # preserved
def test_i_can_convert_tree_with_children(self):
"""Test handling of children list."""
trees = [
{
"id": "root",
"label": "Root",
"children": [
{
"id": "child1",
"label": "Child 1",
"children": [
{"id": "grandchild", "label": "Grandchild"}
]
},
{"id": "child2", "label": "Child 2"}
]
}
]
nodes, edges = from_tree_with_metadata(trees)
assert len(nodes) == 4
assert len(edges) == 3
assert {"from": "root", "to": "child1"} in edges
assert {"from": "root", "to": "child2"} in edges
assert {"from": "child1", "to": "grandchild"} in edges
def test_i_can_use_custom_id_getter(self):
"""Test custom callback for extracting node ID."""
trees = [
{
"node_id": "custom_root",
"label": "Root"
}
]
def custom_id_getter(node):
return node.get("node_id")
nodes, edges = from_tree_with_metadata(
trees,
id_getter=custom_id_getter
)
assert nodes[0]["id"] == "custom_root"
def test_i_can_use_custom_label_getter(self):
"""Test custom callback for extracting node label."""
trees = [
{
"id": "root",
"name": "Custom Label"
}
]
def custom_label_getter(node):
return node.get("name", "")
nodes, edges = from_tree_with_metadata(
trees,
label_getter=custom_label_getter
)
assert nodes[0]["label"] == "Custom Label"
def test_i_can_use_custom_children_getter(self):
"""Test custom callback for extracting children."""
trees = [
{
"id": "root",
"label": "Root",
"kids": [
{"id": "child", "label": "Child"}
]
}
]
def custom_children_getter(node):
return node.get("kids", [])
nodes, edges = from_tree_with_metadata(
trees,
children_getter=custom_children_getter
)
assert len(nodes) == 2
assert nodes[1]["id"] == "child"
def test_i_can_handle_missing_label_with_default(self):
"""Test that missing label returns empty string."""
trees = [{"id": "root"}]
nodes, edges = from_tree_with_metadata(trees)
assert nodes[0]["label"] == ""
def test_i_can_handle_missing_children_with_default(self):
"""Test that missing children returns empty list (no children processed)."""
trees = [{"id": "root", "label": "Root"}]
nodes, edges = from_tree_with_metadata(trees)
assert len(nodes) == 1
assert len(edges) == 0
def test_i_can_convert_multiple_root_trees(self):
"""Test conversion with multiple independent trees with metadata."""
trees = [
{
"id": "root1",
"label": "Root 1",
"children": [
{"id": "child1", "label": "Child 1"}
]
},
{
"id": "root2",
"label": "Root 2",
"children": [
{"id": "child2", "label": "Child 2"}
]
}
]
nodes, edges = from_tree_with_metadata(trees)
assert len(nodes) == 4
assert nodes[0]["id"] == "root1"
assert nodes[1]["id"] == "child1"
assert nodes[2]["id"] == "root2"
assert nodes[3]["id"] == "child2"
# Verify edges connect within trees, not across
assert len(edges) == 2
assert {"from": "root1", "to": "child1"} in edges
assert {"from": "root2", "to": "child2"} in edges
def test_i_can_maintain_id_counter_across_multiple_trees_when_missing_ids(self):
"""Test that auto-increment counter continues across multiple trees."""
trees = [
{"label": "Tree1"},
{"label": "Tree2"},
{"label": "Tree3"}
]
nodes, edges = from_tree_with_metadata(trees)
assert nodes[0]["id"] == 1
assert nodes[1]["id"] == 2
assert nodes[2]["id"] == 3
def test_i_can_convert_empty_list(self):
"""Test that empty list returns empty nodes and edges."""
trees = []
nodes, edges = from_tree_with_metadata(trees)
assert nodes == []
assert edges == []
class TestFromParentChildList:
def test_i_can_convert_single_root_node_without_parent(self):
"""Test conversion of a single root node without parent."""
items = [{"id": "root", "label": "Root"}]
nodes, edges = from_parent_child_list(items)
assert len(nodes) == 1
assert nodes[0] == {'color': '#ff9999', 'id': 'root', 'label': 'Root'}
assert len(edges) == 0
def test_i_can_convert_simple_parent_child_relationship(self):
"""Test conversion with basic parent-child relationship."""
items = [
{"id": "root", "label": "Root"},
{"id": "child", "parent": "root", "label": "Child"}
]
nodes, edges = from_parent_child_list(items)
assert len(nodes) == 2
assert {'color': '#ff9999', 'id': 'root', 'label': 'Root'} in nodes
assert {"id": "child", "label": "Child"} in nodes
assert len(edges) == 1
assert edges[0] == {"from": "root", "to": "child"}
def test_i_can_convert_multiple_children_with_same_parent(self):
"""Test that one parent can have multiple children."""
items = [
{"id": "root", "label": "Root"},
{"id": "child1", "parent": "root", "label": "Child 1"},
{"id": "child2", "parent": "root", "label": "Child 2"}
]
nodes, edges = from_parent_child_list(items)
assert len(nodes) == 3
assert len(edges) == 2
assert {"from": "root", "to": "child1"} in edges
assert {"from": "root", "to": "child2"} in edges
def test_i_can_convert_multi_level_hierarchy(self):
"""Test conversion with multiple levels (root -> child -> grandchild)."""
items = [
{"id": "root", "label": "Root"},
{"id": "child", "parent": "root", "label": "Child"},
{"id": "grandchild", "parent": "child", "label": "Grandchild"}
]
nodes, edges = from_parent_child_list(items)
assert len(nodes) == 3
assert len(edges) == 2
assert {"from": "root", "to": "child"} in edges
assert {"from": "child", "to": "grandchild"} in edges
def test_i_can_handle_parent_none_as_root(self):
"""Test that parent=None identifies a root node."""
items = [
{"id": "root", "parent": None, "label": "Root"},
{"id": "child", "parent": "root", "label": "Child"}
]
nodes, edges = from_parent_child_list(items)
assert len(nodes) == 2
assert len(edges) == 1
assert edges[0] == {"from": "root", "to": "child"}
def test_i_can_handle_parent_empty_string_as_root(self):
"""Test that parent='' identifies a root node."""
items = [
{"id": "root", "parent": "", "label": "Root"},
{"id": "child", "parent": "root", "label": "Child"}
]
nodes, edges = from_parent_child_list(items)
assert len(nodes) == 2
assert len(edges) == 1
assert edges[0] == {"from": "root", "to": "child"}
def test_i_can_create_ghost_node_for_missing_parent(self):
"""Test automatic creation of ghost node when parent doesn't exist."""
items = [
{"id": "child", "parent": "missing_parent", "label": "Child"}
]
nodes, edges = from_parent_child_list(items)
assert len(nodes) == 2
# Find the ghost node
ghost_node = [n for n in nodes if n["id"] == "missing_parent"][0]
assert ghost_node is not None
assert len(edges) == 1
assert edges[0] == {"from": "missing_parent", "to": "child"}
def test_i_can_apply_ghost_color_to_missing_parent(self):
"""Test that ghost nodes have the default ghost color."""
items = [
{"id": "child", "parent": "ghost", "label": "Child"}
]
nodes, edges = from_parent_child_list(items)
ghost_node = [n for n in nodes if n["id"] == "ghost"][0]
assert "color" in ghost_node
assert ghost_node["color"] == "#cccccc"
def test_i_can_use_custom_ghost_color(self):
"""Test that custom ghost_color parameter is applied."""
items = [
{"id": "child", "parent": "ghost", "label": "Child"}
]
nodes, edges = from_parent_child_list(items, ghost_color="#0000ff")
ghost_node = [n for n in nodes if n["id"] == "ghost"][0]
assert ghost_node["color"] == "#0000ff"
def test_i_can_create_multiple_ghost_nodes(self):
"""Test handling of multiple missing parents."""
items = [
{"id": "child1", "parent": "ghost1", "label": "Child 1"},
{"id": "child2", "parent": "ghost2", "label": "Child 2"}
]
nodes, edges = from_parent_child_list(items)
assert len(nodes) == 4 # 2 real + 2 ghost
ghost_ids = [n["id"] for n in nodes if "color" in n]
assert "ghost1" in ghost_ids
assert "ghost2" in ghost_ids
def test_i_can_avoid_duplicate_ghost_nodes(self):
"""Test that same missing parent creates only one ghost node."""
items = [
{"id": "child1", "parent": "ghost", "label": "Child 1"},
{"id": "child2", "parent": "ghost", "label": "Child 2"}
]
nodes, edges = from_parent_child_list(items)
assert len(nodes) == 3 # 2 real + 1 ghost
ghost_nodes = [n for n in nodes if n["id"] == "ghost"]
assert len(ghost_nodes) == 1
assert len(edges) == 2
assert {"from": "ghost", "to": "child1"} in edges
assert {"from": "ghost", "to": "child2"} in edges
def test_i_can_use_custom_id_getter(self):
"""Test custom callback for extracting node ID."""
items = [
{"node_id": "root", "label": "Root"}
]
def custom_id_getter(item):
return item.get("node_id")
nodes, edges = from_parent_child_list(
items,
id_getter=custom_id_getter
)
assert nodes[0]["id"] == "root"
def test_i_can_use_custom_label_getter(self):
"""Test custom callback for extracting node label."""
items = [
{"id": "root", "name": "Custom Label"}
]
def custom_label_getter(item):
return item.get("name", "")
nodes, edges = from_parent_child_list(
items,
label_getter=custom_label_getter
)
assert nodes[0]["label"] == "Custom Label"
def test_i_can_use_custom_parent_getter(self):
"""Test custom callback for extracting parent ID."""
items = [
{"id": "root", "label": "Root"},
{"id": "child", "parent_id": "root", "label": "Child"}
]
def custom_parent_getter(item):
return item.get("parent_id")
nodes, edges = from_parent_child_list(
items,
parent_getter=custom_parent_getter
)
assert len(edges) == 1
assert edges[0] == {"from": "root", "to": "child"}
def test_i_can_handle_empty_list(self):
"""Test that empty list returns empty nodes and edges."""
items = []
nodes, edges = from_parent_child_list(items)
assert nodes == []
assert edges == []
def test_i_can_use_id_as_label_for_ghost_nodes(self):
"""Test that ghost nodes use their ID as label by default."""
items = [
{"id": "child", "parent": "ghost_parent", "label": "Child"}
]
nodes, edges = from_parent_child_list(items)
ghost_node = [n for n in nodes if n["id"] == "ghost_parent"][0]
assert ghost_node["label"] == "ghost_parent"
def test_i_can_apply_root_color_to_single_root(self):
"""Test that a single root node receives the root_color."""
items = [{"id": "root", "label": "Root"}]
nodes, edges = from_parent_child_list(items, root_color="#ff0000")
assert len(nodes) == 1
assert nodes[0]["color"] == "#ff0000"
def test_i_can_apply_root_color_to_multiple_roots(self):
"""Test root_color is assigned to all nodes without parent."""
items = [
{"id": "root1", "label": "Root 1"},
{"id": "root2", "label": "Root 2"},
{"id": "child", "parent": "root1", "label": "Child"}
]
nodes, edges = from_parent_child_list(items, root_color="#aa0000")
root_nodes = [n for n in nodes if n["id"] in ("root1", "root2")]
assert all(n.get("color") == "#aa0000" for n in root_nodes)
# child must NOT have root_color
child_node = next(n for n in nodes if n["id"] == "child")
assert "color" not in child_node
def test_i_can_handle_root_with_parent_none(self):
"""Test that root_color is applied when parent=None."""
items = [
{"id": "r1", "parent": None, "label": "R1"}
]
nodes, edges = from_parent_child_list(items, root_color="#112233")
assert nodes[0]["color"] == "#112233"
def test_i_can_handle_root_with_parent_empty_string(self):
"""Test that root_color is applied when parent=''."""
items = [
{"id": "r1", "parent": "", "label": "R1"}
]
nodes, edges = from_parent_child_list(items, root_color="#334455")
assert nodes[0]["color"] == "#334455"
def test_i_do_not_apply_root_color_to_non_roots(self):
"""Test that only real roots receive root_color."""
items = [
{"id": "root", "label": "Root"},
{"id": "child", "parent": "root", "label": "Child"}
]
nodes, edges = from_parent_child_list(items, root_color="#ff0000")
# Only one root → only this one has the color
root_node = next(n for n in nodes if n["id"] == "root")
assert root_node["color"] == "#ff0000"
child_node = next(n for n in nodes if n["id"] == "child")
assert "color" not in child_node
def test_i_do_not_override_ghost_color_with_root_color(self):
"""Ghost nodes must keep ghost_color, not root_color."""
items = [
{"id": "child", "parent": "ghost_parent", "label": "Child"}
]
nodes, edges = from_parent_child_list(
items,
root_color="#ff0000",
ghost_color="#00ff00"
)
ghost_node = next(n for n in nodes if n["id"] == "ghost_parent")
assert ghost_node["color"] == "#00ff00"
# child is not root → no color
child_node = next(n for n in nodes if n["id"] == "child")
assert "color" not in child_node
def test_i_can_use_custom_root_color(self):
"""Test that a custom root_color is applied instead of default."""
items = [{"id": "root", "label": "Root"}]
nodes, edges = from_parent_child_list(items, root_color="#123456")
assert nodes[0]["color"] == "#123456"
def test_i_can_mix_root_nodes_and_ghost_nodes(self):
"""Ensure root_color applies only to roots and ghost nodes keep ghost_color."""
items = [
{"id": "root", "label": "Root"},
{"id": "child", "parent": "ghost_parent", "label": "Child"}
]
nodes, edges = from_parent_child_list(
items,
root_color="#ff0000",
ghost_color="#00ff00"
)
root_node = next(n for n in nodes if n["id"] == "root")
ghost_node = next(n for n in nodes if n["id"] == "ghost_parent")
assert root_node["color"] == "#ff0000"
assert ghost_node["color"] == "#00ff00"
def test_i_do_not_mark_node_as_root_if_parent_field_exists(self):
"""Node with parent key but non-empty value should NOT get root_color."""
items = [
{"id": "root", "label": "Root"},
{"id": "child", "parent": "root", "label": "Child"},
{"id": "other", "parent": "unknown_parent", "label": "Other"}
]
nodes, edges = from_parent_child_list(
items,
root_color="#ff0000",
ghost_color="#00ff00"
)
# "root" is the only real root
root_node = next(n for n in nodes if n["id"] == "root")
assert root_node["color"] == "#ff0000"
# "other" is NOT root, even though its parent is missing
other_node = next(n for n in nodes if n["id"] == "other")
assert "color" not in other_node
# ghost parent must have ghost_color
ghost_node = next(n for n in nodes if n["id"] == "unknown_parent")
assert ghost_node["color"] == "#00ff00"
def test_i_do_no_add_root_color_when_its_none(self):
"""Test that a single root node receives the root_color."""
items = [{"id": "root", "label": "Root"}]
nodes, edges = from_parent_child_list(items, root_color=None)
assert len(nodes) == 1
assert "color" not in nodes[0]

View File

@@ -0,0 +1,450 @@
/**
* Create keyboard bindings
*/
(function () {
/**
* Global registry to store keyboard shortcuts for multiple elements
*/
const KeyboardRegistry = {
elements: new Map(), // elementId -> { tree, element }
listenerAttached: false,
currentKeys: new Set(),
snapshotHistory: [],
pendingTimeout: null,
pendingMatches: [], // Array of matches waiting for timeout
sequenceTimeout: 500 // 500ms timeout for sequences
};
/**
* Normalize key names to lowercase for case-insensitive comparison
* @param {string} key - The key to normalize
* @returns {string} - Normalized key name
*/
function normalizeKey(key) {
const keyMap = {
'control': 'ctrl',
'escape': 'esc',
'delete': 'del'
};
const normalized = key.toLowerCase();
return keyMap[normalized] || normalized;
}
/**
* Create a unique string key from a Set of keys for Map indexing
* @param {Set} keySet - Set of normalized keys
* @returns {string} - Sorted string representation
*/
function setToKey(keySet) {
return Array.from(keySet).sort().join('+');
}
/**
* Parse a single element (can be a single key or a simultaneous combination)
* @param {string} element - The element string (e.g., "a" or "Ctrl+C")
* @returns {Set} - Set of normalized keys
*/
function parseElement(element) {
if (element.includes('+')) {
// Simultaneous combination
return new Set(element.split('+').map(k => normalizeKey(k.trim())));
}
// Single key
return new Set([normalizeKey(element.trim())]);
}
/**
* Parse a combination string into sequence elements
* @param {string} combination - The combination string (e.g., "Ctrl+C C" or "A B C")
* @returns {Array} - Array of Sets representing the sequence
*/
function parseCombination(combination) {
// Check if it's a sequence (contains space)
if (combination.includes(' ')) {
return combination.split(' ').map(el => parseElement(el.trim()));
}
// Single element (can be a key or simultaneous combination)
return [parseElement(combination)];
}
/**
* Create a new tree node
* @returns {Object} - New tree node
*/
function createTreeNode() {
return {
config: null,
combinationStr: null,
children: new Map()
};
}
/**
* Build a tree from combinations
* @param {Object} combinations - Map of combination strings to HTMX config objects
* @returns {Object} - Root tree node
*/
function buildTree(combinations) {
const root = createTreeNode();
for (const [combinationStr, config] of Object.entries(combinations)) {
const sequence = parseCombination(combinationStr);
console.log("Parsing combination", combinationStr, "=>", sequence);
let currentNode = root;
for (const keySet of sequence) {
const key = setToKey(keySet);
if (!currentNode.children.has(key)) {
currentNode.children.set(key, createTreeNode());
}
currentNode = currentNode.children.get(key);
}
// Mark as end of sequence and store config
currentNode.config = config;
currentNode.combinationStr = combinationStr;
}
return root;
}
/**
* Traverse the tree with the current snapshot history
* @param {Object} treeRoot - Root of the tree
* @param {Array} snapshotHistory - Array of Sets representing pressed keys
* @returns {Object|null} - Current node or null if no match
*/
function traverseTree(treeRoot, snapshotHistory) {
let currentNode = treeRoot;
for (const snapshot of snapshotHistory) {
const key = setToKey(snapshot);
if (!currentNode.children.has(key)) {
return null;
}
currentNode = currentNode.children.get(key);
}
return currentNode;
}
/**
* Check if we're inside an input element where typing should work normally
* @returns {boolean} - True if inside an input-like element
*/
function isInInputContext() {
const activeElement = document.activeElement;
if (!activeElement) return false;
const tagName = activeElement.tagName.toLowerCase();
// Check for input/textarea
if (tagName === 'input' || tagName === 'textarea') {
return true;
}
// Check for contenteditable
if (activeElement.isContentEditable) {
return true;
}
return false;
}
/**
* Trigger an action for a matched combination
* @param {string} elementId - ID of the element
* @param {Object} config - HTMX configuration object
* @param {string} combinationStr - The matched combination string
* @param {boolean} isInside - Whether the focus is inside the element
*/
function triggerAction(elementId, config, combinationStr, isInside) {
const element = document.getElementById(elementId);
if (!element) return;
const hasFocus = document.activeElement === element;
// Extract HTTP method and URL from hx-* attributes
let method = 'POST'; // default
let url = null;
const methodMap = {
'hx-post': 'POST',
'hx-get': 'GET',
'hx-put': 'PUT',
'hx-delete': 'DELETE',
'hx-patch': 'PATCH'
};
for (const [attr, httpMethod] of Object.entries(methodMap)) {
if (config[attr]) {
method = httpMethod;
url = config[attr];
break;
}
}
if (!url) {
console.error('No HTTP method attribute found in config:', config);
return;
}
// Build htmx.ajax options
const htmxOptions = {};
// Map hx-target to target
if (config['hx-target']) {
htmxOptions.target = config['hx-target'];
}
// Map hx-swap to swap
if (config['hx-swap']) {
htmxOptions.swap = config['hx-swap'];
}
// Map hx-vals to values and add combination, has_focus, and is_inside
const values = {};
if (config['hx-vals']) {
Object.assign(values, config['hx-vals']);
}
values.combination = combinationStr;
values.has_focus = hasFocus;
values.is_inside = isInside;
htmxOptions.values = values;
// Add any other hx-* attributes (like hx-headers, hx-select, etc.)
for (const [key, value] of Object.entries(config)) {
if (key.startsWith('hx-') && !['hx-post', 'hx-get', 'hx-put', 'hx-delete', 'hx-patch', 'hx-target', 'hx-swap', 'hx-vals'].includes(key)) {
// Remove 'hx-' prefix and convert to camelCase
const optionKey = key.substring(3).replace(/-([a-z])/g, (g) => g[1].toUpperCase());
htmxOptions[optionKey] = value;
}
}
// Make AJAX call with htmx
htmx.ajax(method, url, htmxOptions);
}
/**
* Handle keyboard events and trigger matching combinations
* @param {KeyboardEvent} event - The keyboard event
*/
function handleKeyboardEvent(event) {
const key = normalizeKey(event.key);
// Add key to current pressed keys
KeyboardRegistry.currentKeys.add(key);
console.debug("Received key", key);
// Create a snapshot of current keyboard state
const snapshot = new Set(KeyboardRegistry.currentKeys);
// Add snapshot to history
KeyboardRegistry.snapshotHistory.push(snapshot);
// Cancel any pending timeout
if (KeyboardRegistry.pendingTimeout) {
clearTimeout(KeyboardRegistry.pendingTimeout);
KeyboardRegistry.pendingTimeout = null;
KeyboardRegistry.pendingMatches = [];
}
// Collect match information for all elements
const currentMatches = [];
let anyHasLongerSequence = false;
let foundAnyMatch = false;
// Check all registered elements for matching combinations
for (const [elementId, data] of KeyboardRegistry.elements) {
const element = document.getElementById(elementId);
if (!element) continue;
// Check if focus is inside this element (element itself or any child)
const isInside = element.contains(document.activeElement);
const treeRoot = data.tree;
// Traverse the tree with current snapshot history
const currentNode = traverseTree(treeRoot, KeyboardRegistry.snapshotHistory);
if (!currentNode) {
// No match in this tree, continue to next element
console.debug("No match in tree for event", key);
continue;
}
// We found at least a partial match
foundAnyMatch = true;
// Check if we have a match (node has a URL)
const hasMatch = currentNode.config !== null;
// Check if there are longer sequences possible (node has children)
const hasLongerSequences = currentNode.children.size > 0;
// Track if ANY element has longer sequences possible
if (hasLongerSequences) {
anyHasLongerSequence = true;
}
// Collect matches
if (hasMatch) {
currentMatches.push({
elementId: elementId,
config: currentNode.config,
combinationStr: currentNode.combinationStr,
isInside: isInside
});
}
}
// Prevent default if we found any match and not in input context
if (currentMatches.length > 0 && !isInInputContext()) {
event.preventDefault();
}
// Decision logic based on matches and longer sequences
if (currentMatches.length > 0 && !anyHasLongerSequence) {
// We have matches and NO element has longer sequences possible
// Trigger ALL matches immediately
for (const match of currentMatches) {
triggerAction(match.elementId, match.config, match.combinationStr, match.isInside);
}
// Clear history after triggering
KeyboardRegistry.snapshotHistory = [];
} else if (currentMatches.length > 0 && anyHasLongerSequence) {
// We have matches but AT LEAST ONE element has longer sequences possible
// Wait for timeout - ALL current matches will be triggered if timeout expires
KeyboardRegistry.pendingMatches = currentMatches;
KeyboardRegistry.pendingTimeout = setTimeout(() => {
// Timeout expired, trigger ALL pending matches
for (const match of KeyboardRegistry.pendingMatches) {
triggerAction(match.elementId, match.config, match.combinationStr, match.isInside);
}
// Clear state
KeyboardRegistry.snapshotHistory = [];
KeyboardRegistry.pendingMatches = [];
KeyboardRegistry.pendingTimeout = null;
}, KeyboardRegistry.sequenceTimeout);
} else if (currentMatches.length === 0 && anyHasLongerSequence) {
// No matches yet but longer sequences are possible
// Just wait, don't trigger anything
} else {
// No matches and no longer sequences possible
// This is an invalid sequence - clear history
KeyboardRegistry.snapshotHistory = [];
}
// If we found no match at all, clear the history
// This handles invalid sequences like "A C" when only "A B" exists
if (!foundAnyMatch) {
KeyboardRegistry.snapshotHistory = [];
}
// Also clear history if it gets too long (prevent memory issues)
if (KeyboardRegistry.snapshotHistory.length > 10) {
KeyboardRegistry.snapshotHistory = [];
}
}
/**
* Handle keyup event to remove keys from current pressed keys
* @param {KeyboardEvent} event - The keyboard event
*/
function handleKeyUp(event) {
const key = normalizeKey(event.key);
KeyboardRegistry.currentKeys.delete(key);
}
/**
* Attach the global keyboard event listener if not already attached
*/
function attachGlobalListener() {
if (!KeyboardRegistry.listenerAttached) {
document.addEventListener('keydown', handleKeyboardEvent);
document.addEventListener('keyup', handleKeyUp);
KeyboardRegistry.listenerAttached = true;
}
}
/**
* Detach the global keyboard event listener
*/
function detachGlobalListener() {
if (KeyboardRegistry.listenerAttached) {
document.removeEventListener('keydown', handleKeyboardEvent);
document.removeEventListener('keyup', handleKeyUp);
KeyboardRegistry.listenerAttached = false;
// Clean up all state
KeyboardRegistry.currentKeys.clear();
KeyboardRegistry.snapshotHistory = [];
if (KeyboardRegistry.pendingTimeout) {
clearTimeout(KeyboardRegistry.pendingTimeout);
KeyboardRegistry.pendingTimeout = null;
}
KeyboardRegistry.pendingMatches = [];
}
}
/**
* Add keyboard support to an element
* @param {string} elementId - The ID of the element
* @param {string} combinationsJson - JSON string of combinations mapping
*/
window.add_keyboard_support = function (elementId, combinationsJson) {
// Parse the combinations JSON
const combinations = JSON.parse(combinationsJson);
// Build tree for this element
const tree = buildTree(combinations);
// Get element reference
const element = document.getElementById(elementId);
if (!element) {
console.error("Element with ID", elementId, "not found!");
return;
}
// Add to registry
KeyboardRegistry.elements.set(elementId, {
tree: tree,
element: element
});
// Attach global listener if not already attached
attachGlobalListener();
};
/**
* Remove keyboard support from an element
* @param {string} elementId - The ID of the element
*/
window.remove_keyboard_support = function (elementId) {
// Remove from registry
if (!KeyboardRegistry.elements.has(elementId)) {
console.warn("Element with ID", elementId, "not found in keyboard registry!");
return;
}
KeyboardRegistry.elements.delete(elementId);
// If no more elements, detach global listeners
if (KeyboardRegistry.elements.size === 0) {
detachGlobalListener();
}
};
})();

634
tests/html/mouse_support.js Normal file
View File

@@ -0,0 +1,634 @@
/**
* Create mouse bindings
*/
(function () {
/**
* Global registry to store mouse shortcuts for multiple elements
*/
const MouseRegistry = {
elements: new Map(), // elementId -> { tree, element }
listenerAttached: false,
snapshotHistory: [],
pendingTimeout: null,
pendingMatches: [], // Array of matches waiting for timeout
sequenceTimeout: 500, // 500ms timeout for sequences
clickHandler: null,
contextmenuHandler: null
};
/**
* Normalize mouse action names
* @param {string} action - The action to normalize
* @returns {string} - Normalized action name
*/
function normalizeAction(action) {
const normalized = action.toLowerCase().trim();
// Handle aliases
const aliasMap = {
'rclick': 'right_click'
};
return aliasMap[normalized] || normalized;
}
/**
* Create a unique string key from a Set of actions for Map indexing
* @param {Set} actionSet - Set of normalized actions
* @returns {string} - Sorted string representation
*/
function setToKey(actionSet) {
return Array.from(actionSet).sort().join('+');
}
/**
* Parse a single element (can be a simple click or click with modifiers)
* @param {string} element - The element string (e.g., "click" or "ctrl+click")
* @returns {Set} - Set of normalized actions
*/
function parseElement(element) {
if (element.includes('+')) {
// Click with modifiers
return new Set(element.split('+').map(a => normalizeAction(a)));
}
// Simple click
return new Set([normalizeAction(element)]);
}
/**
* Parse a combination string into sequence elements
* @param {string} combination - The combination string (e.g., "click right_click")
* @returns {Array} - Array of Sets representing the sequence
*/
function parseCombination(combination) {
// Check if it's a sequence (contains space)
if (combination.includes(' ')) {
return combination.split(' ').map(el => parseElement(el.trim()));
}
// Single element (can be a click or click with modifiers)
return [parseElement(combination)];
}
/**
* Create a new tree node
* @returns {Object} - New tree node
*/
function createTreeNode() {
return {
config: null,
combinationStr: null,
children: new Map()
};
}
/**
* Build a tree from combinations
* @param {Object} combinations - Map of combination strings to HTMX config objects
* @returns {Object} - Root tree node
*/
function buildTree(combinations) {
const root = createTreeNode();
for (const [combinationStr, config] of Object.entries(combinations)) {
const sequence = parseCombination(combinationStr);
console.log("Parsing mouse combination", combinationStr, "=>", sequence);
let currentNode = root;
for (const actionSet of sequence) {
const key = setToKey(actionSet);
if (!currentNode.children.has(key)) {
currentNode.children.set(key, createTreeNode());
}
currentNode = currentNode.children.get(key);
}
// Mark as end of sequence and store config
currentNode.config = config;
currentNode.combinationStr = combinationStr;
}
return root;
}
/**
* Traverse the tree with the current snapshot history
* @param {Object} treeRoot - Root of the tree
* @param {Array} snapshotHistory - Array of Sets representing mouse actions
* @returns {Object|null} - Current node or null if no match
*/
function traverseTree(treeRoot, snapshotHistory) {
let currentNode = treeRoot;
for (const snapshot of snapshotHistory) {
const key = setToKey(snapshot);
if (!currentNode.children.has(key)) {
return null;
}
currentNode = currentNode.children.get(key);
}
return currentNode;
}
/**
* Check if we're inside an input element where clicking should work normally
* @returns {boolean} - True if inside an input-like element
*/
function isInInputContext() {
const activeElement = document.activeElement;
if (!activeElement) return false;
const tagName = activeElement.tagName.toLowerCase();
// Check for input/textarea
if (tagName === 'input' || tagName === 'textarea') {
return true;
}
// Check for contenteditable
if (activeElement.isContentEditable) {
return true;
}
return false;
}
/**
* Get the element that was actually clicked (from registered elements)
* @param {Element} target - The clicked element
* @returns {string|null} - Element ID if found, null otherwise
*/
function findRegisteredElement(target) {
// Check if target itself is registered
if (target.id && MouseRegistry.elements.has(target.id)) {
return target.id;
}
// Check if any parent is registered
let current = target.parentElement;
while (current) {
if (current.id && MouseRegistry.elements.has(current.id)) {
return current.id;
}
current = current.parentElement;
}
return null;
}
/**
* Create a snapshot from mouse event
* @param {MouseEvent} event - The mouse event
* @param {string} baseAction - The base action ('click' or 'right_click')
* @returns {Set} - Set of actions representing this click
*/
function createSnapshot(event, baseAction) {
const actions = new Set([baseAction]);
// Add modifiers if present
if (event.ctrlKey || event.metaKey) {
actions.add('ctrl');
}
if (event.shiftKey) {
actions.add('shift');
}
if (event.altKey) {
actions.add('alt');
}
return actions;
}
/**
* Trigger an action for a matched combination
* @param {string} elementId - ID of the element
* @param {Object} config - HTMX configuration object
* @param {string} combinationStr - The matched combination string
* @param {boolean} isInside - Whether the click was inside the element
*/
function triggerAction(elementId, config, combinationStr, isInside) {
const element = document.getElementById(elementId);
if (!element) return;
const hasFocus = document.activeElement === element;
// Extract HTTP method and URL from hx-* attributes
let method = 'POST'; // default
let url = null;
const methodMap = {
'hx-post': 'POST',
'hx-get': 'GET',
'hx-put': 'PUT',
'hx-delete': 'DELETE',
'hx-patch': 'PATCH'
};
for (const [attr, httpMethod] of Object.entries(methodMap)) {
if (config[attr]) {
method = httpMethod;
url = config[attr];
break;
}
}
if (!url) {
console.error('No HTTP method attribute found in config:', config);
return;
}
// Build htmx.ajax options
const htmxOptions = {};
// Map hx-target to target
if (config['hx-target']) {
htmxOptions.target = config['hx-target'];
}
// Map hx-swap to swap
if (config['hx-swap']) {
htmxOptions.swap = config['hx-swap'];
}
// Map hx-vals to values and add combination, has_focus, and is_inside
const values = {};
if (config['hx-vals']) {
Object.assign(values, config['hx-vals']);
}
values.combination = combinationStr;
values.has_focus = hasFocus;
values.is_inside = isInside;
htmxOptions.values = values;
// Add any other hx-* attributes (like hx-headers, hx-select, etc.)
for (const [key, value] of Object.entries(config)) {
if (key.startsWith('hx-') && !['hx-post', 'hx-get', 'hx-put', 'hx-delete', 'hx-patch', 'hx-target', 'hx-swap', 'hx-vals'].includes(key)) {
// Remove 'hx-' prefix and convert to camelCase
const optionKey = key.substring(3).replace(/-([a-z])/g, (g) => g[1].toUpperCase());
htmxOptions[optionKey] = value;
}
}
// Make AJAX call with htmx
htmx.ajax(method, url, htmxOptions);
}
/**
* Handle mouse events and trigger matching combinations
* @param {MouseEvent} event - The mouse event
* @param {string} baseAction - The base action ('click' or 'right_click')
*/
function handleMouseEvent(event, baseAction) {
// Different behavior for click vs right_click
if (baseAction === 'click') {
// Click: trigger for ALL registered elements (useful for closing modals/popups)
handleGlobalClick(event);
} else if (baseAction === 'right_click') {
// Right-click: trigger ONLY if clicked on a registered element
handleElementRightClick(event);
}
}
/**
* Handle global click events (triggers for all registered elements)
* @param {MouseEvent} event - The mouse event
*/
function handleGlobalClick(event) {
console.debug("Global click detected");
// Create a snapshot of current mouse action with modifiers
const snapshot = createSnapshot(event, 'click');
// Add snapshot to history
MouseRegistry.snapshotHistory.push(snapshot);
// Cancel any pending timeout
if (MouseRegistry.pendingTimeout) {
clearTimeout(MouseRegistry.pendingTimeout);
MouseRegistry.pendingTimeout = null;
MouseRegistry.pendingMatches = [];
}
// Collect match information for ALL registered elements
const currentMatches = [];
let anyHasLongerSequence = false;
let foundAnyMatch = false;
for (const [elementId, data] of MouseRegistry.elements) {
const element = document.getElementById(elementId);
if (!element) continue;
// Check if click was inside this element
const isInside = element.contains(event.target);
const treeRoot = data.tree;
// Traverse the tree with current snapshot history
const currentNode = traverseTree(treeRoot, MouseRegistry.snapshotHistory);
if (!currentNode) {
// No match in this tree
continue;
}
// We found at least a partial match
foundAnyMatch = true;
// Check if we have a match (node has config)
const hasMatch = currentNode.config !== null;
// Check if there are longer sequences possible (node has children)
const hasLongerSequences = currentNode.children.size > 0;
if (hasLongerSequences) {
anyHasLongerSequence = true;
}
// Collect matches
if (hasMatch) {
currentMatches.push({
elementId: elementId,
config: currentNode.config,
combinationStr: currentNode.combinationStr,
isInside: isInside
});
}
}
// Prevent default if we found any match and not in input context
if (currentMatches.length > 0 && !isInInputContext()) {
event.preventDefault();
}
// Decision logic based on matches and longer sequences
if (currentMatches.length > 0 && !anyHasLongerSequence) {
// We have matches and NO longer sequences possible
// Trigger ALL matches immediately
for (const match of currentMatches) {
triggerAction(match.elementId, match.config, match.combinationStr, match.isInside);
}
// Clear history after triggering
MouseRegistry.snapshotHistory = [];
} else if (currentMatches.length > 0 && anyHasLongerSequence) {
// We have matches but longer sequences are possible
// Wait for timeout - ALL current matches will be triggered if timeout expires
MouseRegistry.pendingMatches = currentMatches;
MouseRegistry.pendingTimeout = setTimeout(() => {
// Timeout expired, trigger ALL pending matches
for (const match of MouseRegistry.pendingMatches) {
triggerAction(match.elementId, match.config, match.combinationStr, match.isInside);
}
// Clear state
MouseRegistry.snapshotHistory = [];
MouseRegistry.pendingMatches = [];
MouseRegistry.pendingTimeout = null;
}, MouseRegistry.sequenceTimeout);
} else if (currentMatches.length === 0 && anyHasLongerSequence) {
// No matches yet but longer sequences are possible
// Just wait, don't trigger anything
} else {
// No matches and no longer sequences possible
// This is an invalid sequence - clear history
MouseRegistry.snapshotHistory = [];
}
// If we found no match at all, clear the history
if (!foundAnyMatch) {
MouseRegistry.snapshotHistory = [];
}
// Also clear history if it gets too long (prevent memory issues)
if (MouseRegistry.snapshotHistory.length > 10) {
MouseRegistry.snapshotHistory = [];
}
}
/**
* Handle right-click events (triggers only for clicked element)
* @param {MouseEvent} event - The mouse event
*/
function handleElementRightClick(event) {
// Find which registered element was clicked
const elementId = findRegisteredElement(event.target);
if (!elementId) {
// Right-click wasn't on a registered element - don't prevent default
// This allows browser context menu to appear
return;
}
console.debug("Right-click on registered element", elementId);
// For right-click, clicked_inside is always true (we only trigger if clicked on element)
const clickedInside = true;
// Create a snapshot of current mouse action with modifiers
const snapshot = createSnapshot(event, 'right_click');
// Add snapshot to history
MouseRegistry.snapshotHistory.push(snapshot);
// Cancel any pending timeout
if (MouseRegistry.pendingTimeout) {
clearTimeout(MouseRegistry.pendingTimeout);
MouseRegistry.pendingTimeout = null;
MouseRegistry.pendingMatches = [];
}
// Collect match information for this element
const currentMatches = [];
let anyHasLongerSequence = false;
let foundAnyMatch = false;
const data = MouseRegistry.elements.get(elementId);
if (!data) return;
const treeRoot = data.tree;
// Traverse the tree with current snapshot history
const currentNode = traverseTree(treeRoot, MouseRegistry.snapshotHistory);
if (!currentNode) {
// No match in this tree
console.debug("No match in tree for right-click");
// Clear history for invalid sequences
MouseRegistry.snapshotHistory = [];
return;
}
// We found at least a partial match
foundAnyMatch = true;
// Check if we have a match (node has config)
const hasMatch = currentNode.config !== null;
// Check if there are longer sequences possible (node has children)
const hasLongerSequences = currentNode.children.size > 0;
if (hasLongerSequences) {
anyHasLongerSequence = true;
}
// Collect matches
if (hasMatch) {
currentMatches.push({
elementId: elementId,
config: currentNode.config,
combinationStr: currentNode.combinationStr,
isInside: true // Right-click only triggers when clicking on element
});
}
// Prevent default if we found any match and not in input context
if (currentMatches.length > 0 && !isInInputContext()) {
event.preventDefault();
}
// Decision logic based on matches and longer sequences
if (currentMatches.length > 0 && !anyHasLongerSequence) {
// We have matches and NO longer sequences possible
// Trigger ALL matches immediately
for (const match of currentMatches) {
triggerAction(match.elementId, match.config, match.combinationStr, match.isInside);
}
// Clear history after triggering
MouseRegistry.snapshotHistory = [];
} else if (currentMatches.length > 0 && anyHasLongerSequence) {
// We have matches but longer sequences are possible
// Wait for timeout - ALL current matches will be triggered if timeout expires
MouseRegistry.pendingMatches = currentMatches;
MouseRegistry.pendingTimeout = setTimeout(() => {
// Timeout expired, trigger ALL pending matches
for (const match of MouseRegistry.pendingMatches) {
triggerAction(match.elementId, match.config, match.combinationStr, match.isInside);
}
// Clear state
MouseRegistry.snapshotHistory = [];
MouseRegistry.pendingMatches = [];
MouseRegistry.pendingTimeout = null;
}, MouseRegistry.sequenceTimeout);
} else if (currentMatches.length === 0 && anyHasLongerSequence) {
// No matches yet but longer sequences are possible
// Just wait, don't trigger anything
} else {
// No matches and no longer sequences possible
// This is an invalid sequence - clear history
MouseRegistry.snapshotHistory = [];
}
// If we found no match at all, clear the history
if (!foundAnyMatch) {
MouseRegistry.snapshotHistory = [];
}
// Also clear history if it gets too long (prevent memory issues)
if (MouseRegistry.snapshotHistory.length > 10) {
MouseRegistry.snapshotHistory = [];
}
}
/**
* Attach the global mouse event listeners if not already attached
*/
function attachGlobalListener() {
if (!MouseRegistry.listenerAttached) {
// Store handler references for proper removal
MouseRegistry.clickHandler = (e) => handleMouseEvent(e, 'click');
MouseRegistry.contextmenuHandler = (e) => handleMouseEvent(e, 'right_click');
document.addEventListener('click', MouseRegistry.clickHandler);
document.addEventListener('contextmenu', MouseRegistry.contextmenuHandler);
MouseRegistry.listenerAttached = true;
}
}
/**
* Detach the global mouse event listeners
*/
function detachGlobalListener() {
if (MouseRegistry.listenerAttached) {
document.removeEventListener('click', MouseRegistry.clickHandler);
document.removeEventListener('contextmenu', MouseRegistry.contextmenuHandler);
MouseRegistry.listenerAttached = false;
// Clean up handler references
MouseRegistry.clickHandler = null;
MouseRegistry.contextmenuHandler = null;
// Clean up all state
MouseRegistry.snapshotHistory = [];
if (MouseRegistry.pendingTimeout) {
clearTimeout(MouseRegistry.pendingTimeout);
MouseRegistry.pendingTimeout = null;
}
MouseRegistry.pendingMatches = [];
}
}
/**
* Add mouse support to an element
* @param {string} elementId - The ID of the element
* @param {string} combinationsJson - JSON string of combinations mapping
*/
window.add_mouse_support = function (elementId, combinationsJson) {
// Parse the combinations JSON
const combinations = JSON.parse(combinationsJson);
// Build tree for this element
const tree = buildTree(combinations);
// Get element reference
const element = document.getElementById(elementId);
if (!element) {
console.error("Element with ID", elementId, "not found!");
return;
}
// Add to registry
MouseRegistry.elements.set(elementId, {
tree: tree,
element: element
});
// Attach global listener if not already attached
attachGlobalListener();
};
/**
* Remove mouse support from an element
* @param {string} elementId - The ID of the element
*/
window.remove_mouse_support = function (elementId) {
// Remove from registry
if (!MouseRegistry.elements.has(elementId)) {
console.warn("Element with ID", elementId, "not found in mouse registry!");
return;
}
MouseRegistry.elements.delete(elementId);
// If no more elements, detach global listeners
if (MouseRegistry.elements.size === 0) {
detachGlobalListener();
}
};
})();

View File

@@ -0,0 +1,309 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Keyboard Support Test</title>
<style>
body {
font-family: Arial, sans-serif;
max-width: 1200px;
margin: 20px auto;
padding: 0 20px;
}
.test-container {
border: 2px solid #333;
padding: 20px;
margin: 20px 0;
border-radius: 5px;
}
.test-element {
background-color: #f0f0f0;
border: 2px solid #999;
padding: 30px;
text-align: center;
border-radius: 5px;
cursor: pointer;
margin: 10px 0;
}
.test-element:focus {
background-color: #e3f2fd;
border-color: #2196F3;
outline: none;
}
.log-container {
background-color: #1e1e1e;
color: #d4d4d4;
padding: 15px;
border-radius: 5px;
max-height: 400px;
overflow-y: auto;
font-family: 'Courier New', monospace;
font-size: 14px;
margin-top: 20px;
}
.log-entry {
margin: 5px 0;
padding: 5px;
border-left: 3px solid #4CAF50;
padding-left: 10px;
}
.log-entry.focus {
border-left-color: #2196F3;
}
.log-entry.no-focus {
border-left-color: #FF9800;
}
.shortcuts-list {
background-color: #fff3cd;
border: 1px solid #ffc107;
padding: 15px;
border-radius: 5px;
margin: 10px 0;
}
.shortcuts-list h3 {
margin-top: 0;
}
.shortcuts-list ul {
margin: 10px 0;
padding-left: 20px;
}
.shortcuts-list code {
background-color: #f5f5f5;
padding: 2px 6px;
border-radius: 3px;
font-family: 'Courier New', monospace;
}
.clear-button {
background-color: #f44336;
color: white;
border: none;
padding: 10px 20px;
border-radius: 5px;
cursor: pointer;
font-size: 14px;
}
.clear-button:hover {
background-color: #d32f2f;
}
h1, h2 {
color: #333;
}
</style>
</head>
<body>
<h1>Keyboard Support Test Page</h1>
<div class="shortcuts-list">
<h3>📋 Configured Shortcuts (with HTMX options)</h3>
<p><strong>Simple keys:</strong></p>
<ul>
<li><code>a</code> - Simple key A (POST to /test/key-a)</li>
<li><code>esc</code> - Escape key (POST)</li>
</ul>
<p><strong>Simultaneous combinations with HTMX options:</strong></p>
<ul>
<li><code>Ctrl+S</code> - Save (POST with swap: innerHTML)</li>
<li><code>Ctrl+C</code> - Copy (POST with target: #result, swap: outerHTML)</li>
</ul>
<p><strong>Sequences with various configs:</strong></p>
<ul>
<li><code>A B</code> - Sequence (POST with extra values: {"extra": "data"})</li>
<li><code>A B C</code> - Triple sequence (GET request)</li>
<li><code>shift shift</code> - Press Shift twice in sequence</li>
</ul>
<p><strong>Complex:</strong></p>
<ul>
<li><code>Ctrl+C C</code> - Ctrl+C then C alone</li>
<li><code>Ctrl+C Ctrl+C</code> - Ctrl+C twice</li>
</ul>
<p><strong>Tip:</strong> Check the log to see how HTMX options (target, swap, extra values) are passed!</p>
</div>
<div class="test-container">
<h2>Test Input (typing should work normally here)</h2>
<input type="text" placeholder="Try typing Ctrl+C, Ctrl+A here - should work normally" style="width: 100%; padding: 10px; font-size: 14px;">
<p style="margin-top: 10px; padding: 10px; background-color: #e3f2fd; border-left: 4px solid #2196F3; border-radius: 3px;">
<strong>Parameters Explained:</strong><br>
<code>has_focus</code>: Whether the registered element itself has focus<br>
<code>is_inside</code>: Whether the focus is on the registered element or any of its children<br>
<em>Example: If focus is on this input and its parent div is registered, has_focus=false but is_inside=true</em>
</p>
</div>
<div class="test-container">
<h2>Test Element 1</h2>
<div id="test-element" class="test-element" tabindex="0">
Click me to focus, then try keyboard shortcuts
</div>
</div>
<div class="test-container">
<h2>Test Element 2 (also listens to ESC and Shift Shift)</h2>
<div id="test-element-2" class="test-element" tabindex="0">
This element also responds to ESC and Shift Shift
</div>
<button class="clear-button" onclick="removeElement2()" style="background-color: #FF5722; margin-top: 10px;">
Remove Element 2 Keyboard Support
</button>
</div>
<div class="test-container">
<h2>Event Log</h2>
<button class="clear-button" onclick="clearLog()">Clear Log</button>
<div id="log" class="log-container"></div>
</div>
<!-- Include htmx -->
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<!-- Mock htmx.ajax for testing -->
<script>
// Store original htmx.ajax if it exists
const originalHtmxAjax = window.htmx && window.htmx.ajax;
// Override htmx.ajax for testing purposes
if (window.htmx) {
window.htmx.ajax = function(method, url, config) {
const timestamp = new Date().toLocaleTimeString();
const hasFocus = config.values.has_focus;
const isInside = config.values.is_inside;
const combination = config.values.combination;
// Build details string with all config options
const details = [
`Combination: "${combination}"`,
`Element has focus: ${hasFocus}`,
`Focus inside element: ${isInside}`
];
if (config.target) {
details.push(`Target: ${config.target}`);
}
if (config.swap) {
details.push(`Swap: ${config.swap}`);
}
if (config.values) {
const extraVals = Object.keys(config.values).filter(k => k !== 'combination' && k !== 'has_focus');
if (extraVals.length > 0) {
details.push(`Extra values: ${JSON.stringify(extraVals.reduce((obj, k) => ({...obj, [k]: config.values[k]}), {}))}`);
}
}
logEvent(
`[${timestamp}] ${method} ${url}`,
...details,
hasFocus
);
// Uncomment below to use real htmx.ajax if you have a backend
// if (originalHtmxAjax) {
// originalHtmxAjax.call(this, method, url, config);
// }
};
}
function logEvent(title, ...details) {
const log = document.getElementById('log');
const hasFocus = details[details.length - 1];
const entry = document.createElement('div');
entry.className = `log-entry ${hasFocus ? 'focus' : 'no-focus'}`;
entry.innerHTML = `
<strong>${title}</strong><br>
${details.slice(0, -1).join('<br>')}
`;
log.insertBefore(entry, log.firstChild);
}
function clearLog() {
document.getElementById('log').innerHTML = '';
}
function removeElement2() {
remove_keyboard_support('test-element-2');
logEvent('Element 2 keyboard support removed',
'ESC and Shift Shift no longer trigger for Element 2',
'Element 1 still active', false);
// Disable the button
event.target.disabled = true;
event.target.textContent = 'Keyboard Support Removed';
}
</script>
<!-- Include keyboard support script -->
<script src="keyboard_support.js"></script>
<!-- Initialize keyboard support -->
<script>
const combinations = {
"a": {
"hx-post": "/test/key-a"
},
"Ctrl+S": {
"hx-post": "/test/save",
"hx-swap": "innerHTML"
},
"Ctrl+C": {
"hx-post": "/test/copy",
"hx-target": "#result",
"hx-swap": "outerHTML"
},
"A B": {
"hx-post": "/test/sequence-ab",
"hx-vals": {"extra": "data"}
},
"A B C": {
"hx-get": "/test/sequence-abc"
},
"Ctrl+C C": {
"hx-post": "/test/complex-ctrl-c-c"
},
"Ctrl+C Ctrl+C": {
"hx-post": "/test/complex-ctrl-c-twice"
},
"shift shift": {
"hx-post": "/test/shift-shift"
},
"esc": {
"hx-post": "/test/escape"
}
};
add_keyboard_support('test-element', JSON.stringify(combinations));
// Add second element that also listens to ESC and shift shift
const combinations2 = {
"esc": {
"hx-post": "/test/escape-element2"
},
"shift shift": {
"hx-post": "/test/shift-shift-element2"
}
};
add_keyboard_support('test-element-2', JSON.stringify(combinations2));
// Log initial state
logEvent('Keyboard support initialized',
'Element 1: All shortcuts configured with HTMX options',
'Element 2: ESC and Shift Shift (will trigger simultaneously with Element 1)',
'Smart timeout enabled: waits 500ms only if longer sequence exists', false);
</script>
</body>
</html>

View File

@@ -0,0 +1,356 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Mouse Support Test</title>
<style>
body {
font-family: Arial, sans-serif;
max-width: 1200px;
margin: 20px auto;
padding: 0 20px;
}
.test-container {
border: 2px solid #333;
padding: 20px;
margin: 20px 0;
border-radius: 5px;
}
.test-element {
background-color: #f0f0f0;
border: 2px solid #999;
padding: 30px;
text-align: center;
border-radius: 5px;
cursor: pointer;
margin: 10px 0;
user-select: none;
}
.test-element:hover {
background-color: #e0e0e0;
}
.test-element:focus {
background-color: #e3f2fd;
border-color: #2196F3;
outline: none;
}
.log-container {
background-color: #1e1e1e;
color: #d4d4d4;
padding: 15px;
border-radius: 5px;
max-height: 400px;
overflow-y: auto;
font-family: 'Courier New', monospace;
font-size: 14px;
margin-top: 20px;
}
.log-entry {
margin: 5px 0;
padding: 5px;
border-left: 3px solid #4CAF50;
padding-left: 10px;
}
.log-entry.focus {
border-left-color: #2196F3;
}
.log-entry.no-focus {
border-left-color: #FF9800;
}
.actions-list {
background-color: #fff3cd;
border: 1px solid #ffc107;
padding: 15px;
border-radius: 5px;
margin: 10px 0;
}
.actions-list h3 {
margin-top: 0;
}
.actions-list ul {
margin: 10px 0;
padding-left: 20px;
}
.actions-list code {
background-color: #f5f5f5;
padding: 2px 6px;
border-radius: 3px;
font-family: 'Courier New', monospace;
}
.clear-button {
background-color: #f44336;
color: white;
border: none;
padding: 10px 20px;
border-radius: 5px;
cursor: pointer;
font-size: 14px;
}
.clear-button:hover {
background-color: #d32f2f;
}
.remove-button {
background-color: #FF5722;
color: white;
border: none;
padding: 10px 20px;
border-radius: 5px;
cursor: pointer;
font-size: 14px;
margin-top: 10px;
}
.remove-button:hover {
background-color: #E64A19;
}
.remove-button:disabled {
background-color: #ccc;
cursor: not-allowed;
}
h1, h2 {
color: #333;
}
.note {
background-color: #e3f2fd;
border-left: 4px solid #2196F3;
padding: 10px 15px;
margin: 10px 0;
border-radius: 3px;
}
</style>
</head>
<body>
<h1>Mouse Support Test Page</h1>
<div class="actions-list">
<h3>🖱️ Configured Mouse Actions</h3>
<p><strong>Element 1 - All Actions:</strong></p>
<ul>
<li><code>click</code> - Simple left click</li>
<li><code>right_click</code> - Right click (context menu blocked)</li>
<li><code>ctrl+click</code> - Ctrl/Cmd + Click</li>
<li><code>shift+click</code> - Shift + Click</li>
<li><code>ctrl+shift+click</code> - Ctrl + Shift + Click</li>
<li><code>click right_click</code> - Click then right-click within 500ms</li>
<li><code>click click</code> - Click twice in sequence</li>
</ul>
<p><strong>Element 2 - Using rclick alias:</strong></p>
<ul>
<li><code>click</code> - Simple click</li>
<li><code>rclick</code> - Right click (using rclick alias)</li>
<li><code>click rclick</code> - Click then right-click sequence (using alias)</li>
</ul>
<p><strong>Note:</strong> <code>rclick</code> is an alias for <code>right_click</code> and works identically.</p>
<p><strong>Tip:</strong> Try different click combinations! Right-click menu will be blocked on test elements.</p>
</div>
<div class="note">
<strong>Click Behavior:</strong> The <code>click</code> action is detected GLOBALLY (anywhere on the page).
Try clicking outside the test elements - the click action will still trigger! The <code>is_inside</code>
parameter tells you if the click was inside or outside the element (perfect for "close popup if clicked outside" logic).
</div>
<div class="note">
<strong>Right-Click Behavior:</strong> The <code>right_click</code> action is detected ONLY when clicking ON the element.
Try right-clicking outside the test elements - the browser's context menu will appear normally.
</div>
<div class="note">
<strong>Mac Users:</strong> Use Cmd (⌘) instead of Ctrl. The library handles cross-platform compatibility automatically.
</div>
<div class="test-container">
<h2>Test Element 1 (All Actions)</h2>
<div id="test-element-1" class="test-element" tabindex="0">
Try different mouse actions here!<br>
Click, Right-click, Ctrl+Click, Shift+Click, sequences...
</div>
</div>
<div class="test-container">
<h2>Test Element 2 (Using rclick alias)</h2>
<div id="test-element-2" class="test-element" tabindex="0">
This element uses "rclick" alias for right-click<br>
Also has a "click rclick" sequence
</div>
<button class="remove-button" onclick="removeElement2()">
Remove Element 2 Mouse Support
</button>
</div>
<div class="test-container">
<h2>Test Input (normal clicking should work here)</h2>
<input type="text" placeholder="Try clicking, right-clicking here - should work normally"
style="width: 100%; padding: 10px; font-size: 14px;">
</div>
<div class="test-container" style="background-color: #f9f9f9;">
<h2>🎯 Click Outside Test Area</h2>
<p>Click anywhere in this gray area (outside the test elements above) to see that <code>click</code> is detected globally!</p>
<p style="margin-top: 20px; padding: 30px; background-color: white; border: 2px dashed #999; border-radius: 5px; text-align: center;">
This is just empty space - but clicking here will still trigger the registered <code>click</code> actions!
</p>
</div>
<div class="test-container">
<h2>Event Log</h2>
<button class="clear-button" onclick="clearLog()">Clear Log</button>
<div id="log" class="log-container"></div>
</div>
<!-- Include htmx -->
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<!-- Mock htmx.ajax for testing -->
<script>
// Store original htmx.ajax if it exists
const originalHtmxAjax = window.htmx && window.htmx.ajax;
// Override htmx.ajax for testing purposes
if (window.htmx) {
window.htmx.ajax = function(method, url, config) {
const timestamp = new Date().toLocaleTimeString();
const hasFocus = config.values.has_focus;
const isInside = config.values.is_inside;
const combination = config.values.combination;
// Build details string with all config options
const details = [
`Combination: "${combination}"`,
`Element has focus: ${hasFocus}`,
`Click inside element: ${isInside}`
];
if (config.target) {
details.push(`Target: ${config.target}`);
}
if (config.swap) {
details.push(`Swap: ${config.swap}`);
}
if (config.values) {
const extraVals = Object.keys(config.values).filter(k => k !== 'combination' && k !== 'has_focus' && k !== 'is_inside');
if (extraVals.length > 0) {
details.push(`Extra values: ${JSON.stringify(extraVals.reduce((obj, k) => ({...obj, [k]: config.values[k]}), {}))}`);
}
}
logEvent(
`[${timestamp}] ${method} ${url}`,
...details,
hasFocus
);
// Uncomment below to use real htmx.ajax if you have a backend
// if (originalHtmxAjax) {
// originalHtmxAjax.call(this, method, url, config);
// }
};
}
function logEvent(title, ...details) {
const log = document.getElementById('log');
const hasFocus = details[details.length - 1];
const entry = document.createElement('div');
entry.className = `log-entry ${hasFocus ? 'focus' : 'no-focus'}`;
entry.innerHTML = `
<strong>${title}</strong><br>
${details.slice(0, -1).join('<br>')}
`;
log.insertBefore(entry, log.firstChild);
}
function clearLog() {
document.getElementById('log').innerHTML = '';
}
function removeElement2() {
remove_mouse_support('test-element-2');
logEvent('Element 2 mouse support removed',
'Click and right-click no longer trigger for Element 2',
'Element 1 still active', false);
// Disable the button
event.target.disabled = true;
event.target.textContent = 'Mouse Support Removed';
}
</script>
<!-- Include mouse support script -->
<script src="mouse_support.js"></script>
<!-- Initialize mouse support -->
<script>
// Element 1 - Full configuration
const combinations1 = {
"click": {
"hx-post": "/test/click"
},
"right_click": {
"hx-post": "/test/right-click"
},
"ctrl+click": {
"hx-post": "/test/ctrl-click",
"hx-swap": "innerHTML"
},
"shift+click": {
"hx-post": "/test/shift-click",
"hx-target": "#result"
},
"ctrl+shift+click": {
"hx-post": "/test/ctrl-shift-click",
"hx-vals": {"modifier": "both"}
},
"click right_click": {
"hx-post": "/test/click-then-right-click",
"hx-vals": {"type": "sequence"}
},
"click click": {
"hx-post": "/test/double-click-sequence"
}
};
add_mouse_support('test-element-1', JSON.stringify(combinations1));
// Element 2 - Using rclick alias
const combinations2 = {
"click": {
"hx-post": "/test/element2-click"
},
"rclick": { // Using rclick alias instead of right_click
"hx-post": "/test/element2-rclick"
},
"click rclick": { // Sequence using rclick alias
"hx-post": "/test/element2-click-rclick-sequence"
}
};
add_mouse_support('test-element-2', JSON.stringify(combinations2));
// Log initial state
logEvent('Mouse support initialized',
'Element 1: All mouse actions configured',
'Element 2: Using "rclick" alias (click, rclick, and click rclick sequence)',
'Smart timeout: 500ms for sequences', false);
</script>
</body>
</html>

View File

@@ -0,0 +1,42 @@
import pytest
from fasthtml.components import Div, Span
from myfasthtml.test.matcher import find
@pytest.mark.parametrize('ft, expected', [
("hello", "hello"),
(Div(id="id1"), Div(id="id1")),
(Div(Span(id="span_id"), id="div_id1"), Div(Span(id="span_id"), id="div_id1")),
(Div(id="id1", id2="id2"), Div(id="id1")),
(Div(Div(id="id2"), id2="id1"), Div(id="id1")),
])
def test_i_can_find(ft, expected):
assert find(expected, expected) == [expected]
def test_find_element_by_id_in_a_list():
a = Div(id="id1")
b = Div(id="id2")
c = Div(id="id3")
assert find([a, b, c], b) == [b]
def test_i_can_find_sub_element():
a = Div(id="id1")
b = Div(a, id="id2")
c = Div(b, id="id3")
assert find(c, a) == [a]
@pytest.mark.parametrize('ft, expected', [
(None, Div(id="id1")),
(Span(id="id1"), Div(id="id1")),
(Div(id2="id1"), Div(id="id1")),
(Div(id="id2"), Div(id="id1")),
])
def test_i_cannot_find(ft, expected):
with pytest.raises(AssertionError):
find(expected, ft)

View File

@@ -3,7 +3,7 @@ from fastcore.basics import NotStr
from fasthtml.components import *
from myfasthtml.test.matcher import matches, StartsWith, Contains, DoesNotContain, Empty, DoNotCheck, ErrorOutput, \
ErrorComparisonOutput, AttributeForbidden, AnyValue
ErrorComparisonOutput, AttributeForbidden, AnyValue, NoChildren
from myfasthtml.test.testclient import MyFT
@@ -24,6 +24,8 @@ from myfasthtml.test.testclient import MyFT
([Div(), Span()], DoNotCheck()),
(NotStr("123456"), NotStr("123")), # for NotStr, only the beginning is checked
(Div(), Div(Empty())),
(Div(), Div(NoChildren())),
(Div(attr1="value"), Div(NoChildren())),
(Div(attr1="value1"), Div(AttributeForbidden("attr2"))),
(Div(123), Div(123)),
(Div(Span(123)), Div(Span(123))),
@@ -54,6 +56,7 @@ def test_i_can_match(actual, expected):
(Div(), Div(attr1=AnyValue()), "'attr1' is not found in Actual"),
(NotStr("456"), NotStr("123"), "Notstr values are different"),
(Div(attr="value"), Div(Empty()), "The condition 'Empty()' is not satisfied"),
(Div(Span()), Div(NoChildren()), "The condition 'NoChildren()' is not satisfied"),
(Div(120), Div(Empty()), "The condition 'Empty()' is not satisfied"),
(Div(Span()), Div(Empty()), "The condition 'Empty()' is not satisfied"),
(Div(), Div(Span()), "Actual is lesser than expected"),
@@ -203,6 +206,7 @@ def test_i_can_output_error_child_element():
')',
]
def test_i_can_output_error_child_element_text():
"""I can display error when the children is not a FT"""
elt = Div("Hello world", Div(id="child_1"), Div(id="child_2"), attr1="value1")
@@ -217,6 +221,7 @@ def test_i_can_output_error_child_element_text():
')',
]
def test_i_can_output_error_child_element_indicating_sub_children():
elt = Div(P(id="p_id"), Div(Div(id="child_2"), id="child_1"), attr1="value1")
expected = elt