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

@@ -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]