"""Unit tests for Layout component.""" import shutil import pytest from fasthtml.components import * from myfasthtml.controls.Layout import Layout, LayoutState from myfasthtml.controls.UserProfile import UserProfile from myfasthtml.test.matcher import matches, find, Contains, NotStr, TestObject from .conftest import root_instance @pytest.fixture(autouse=True) def cleanup_db(): shutil.rmtree(".myFastHtmlDb", ignore_errors=True) class TestLayoutBehaviour: """Tests for Layout behavior and logic.""" def test_i_can_create_layout(self, root_instance): """Test basic layout creation with app_name.""" layout = Layout(root_instance, app_name="Test App") assert layout is not None assert layout.app_name == "Test App" assert layout._state is not None def test_i_can_set_main_content(self, root_instance): """Test setting main content area.""" layout = Layout(root_instance, app_name="Test App") content = Div("Main content") result = layout.set_main(content) assert layout._main_content == content assert result == layout # Should return self for chaining def test_i_can_set_footer_content(self, root_instance): """Test setting footer content.""" layout = Layout(root_instance, app_name="Test App") content = Div("Footer content") layout.set_footer(content) assert layout._footer_content == content def test_i_can_add_content_to_left_drawer(self, root_instance): """Test adding content to left drawer.""" layout = Layout(root_instance, app_name="Test App") content = Div("Left drawer content", id="drawer_item") layout.left_drawer.add(content) drawer_content = layout.left_drawer.get_content() assert None in drawer_content assert content in drawer_content[None] def test_i_can_add_content_to_right_drawer(self, root_instance): """Test adding content to right drawer.""" layout = Layout(root_instance, app_name="Test App") content = Div("Right drawer content", id="drawer_item") layout.right_drawer.add(content) drawer_content = layout.right_drawer.get_content() assert None in drawer_content assert content in drawer_content[None] def test_i_can_add_content_to_header_left(self, root_instance): """Test adding content to left side of header.""" layout = Layout(root_instance, app_name="Test App") content = Div("Header left content", id="header_item") layout.header_left.add(content) header_content = layout.header_left.get_content() assert None in header_content assert content in header_content[None] def test_i_can_add_content_to_header_right(self, root_instance): """Test adding content to right side of header.""" layout = Layout(root_instance, app_name="Test App") content = Div("Header right content", id="header_item") layout.header_right.add(content) header_content = layout.header_right.get_content() assert None in header_content assert content in header_content[None] def test_i_can_add_grouped_content(self, root_instance): """Test adding content with custom groups.""" layout = Layout(root_instance, app_name="Test App") group_name = "navigation" content1 = Div("Nav item 1", id="nav1") content2 = Div("Nav item 2", id="nav2") layout.left_drawer.add(content1, group=group_name) layout.left_drawer.add(content2, group=group_name) drawer_content = layout.left_drawer.get_content() assert group_name in drawer_content assert content1 in drawer_content[group_name] assert content2 in drawer_content[group_name] def test_i_cannot_add_duplicate_content(self, root_instance): """Test that content with same ID is not added twice.""" layout = Layout(root_instance, app_name="Test App") content = Div("Content", id="unique_id") layout.left_drawer.add(content) layout.left_drawer.add(content) # Try to add again drawer_content = layout.left_drawer.get_content() # Content should appear only once assert drawer_content[None].count(content) == 1 def test_i_can_toggle_left_drawer(self, root_instance): """Test toggling left drawer open/closed.""" layout = Layout(root_instance, app_name="Test App") # Initially open assert layout._state.left_drawer_open is True # Toggle to close layout.toggle_drawer("left") assert layout._state.left_drawer_open is False # Toggle to open layout.toggle_drawer("left") assert layout._state.left_drawer_open is True def test_i_can_toggle_right_drawer(self, root_instance): """Test toggling right drawer open/closed.""" layout = Layout(root_instance, app_name="Test App") # Initially open assert layout._state.right_drawer_open is True # Toggle to close layout.toggle_drawer("right") assert layout._state.right_drawer_open is False # Toggle to open layout.toggle_drawer("right") assert layout._state.right_drawer_open is True def test_i_can_update_left_drawer_width(self, root_instance): """Test updating left drawer width.""" layout = Layout(root_instance, app_name="Test App") new_width = 300 layout.update_drawer_width("left", new_width) assert layout._state.left_drawer_width == new_width def test_i_can_update_right_drawer_width(self, root_instance): """Test updating right drawer width.""" layout = Layout(root_instance, app_name="Test App") new_width = 400 layout.update_drawer_width("right", new_width) assert layout._state.right_drawer_width == new_width def test_i_cannot_set_drawer_width_below_minimum(self, root_instance): """Test that drawer width is constrained to minimum 150px.""" layout = Layout(root_instance, app_name="Test App") layout.update_drawer_width("left", 100) # Try to set below minimum assert layout._state.left_drawer_width == 150 # Should be clamped to min def test_i_cannot_set_drawer_width_above_maximum(self, root_instance): """Test that drawer width is constrained to maximum 600px.""" layout = Layout(root_instance, app_name="Test App") layout.update_drawer_width("right", 800) # Try to set above maximum assert layout._state.right_drawer_width == 600 # Should be clamped to max def test_i_cannot_toggle_invalid_drawer_side(self, root_instance): """Test that toggling invalid drawer side raises ValueError.""" layout = Layout(root_instance, app_name="Test App") with pytest.raises(ValueError, match="Invalid drawer side"): layout.toggle_drawer("invalid") def test_i_cannot_update_invalid_drawer_width(self, root_instance): """Test that updating invalid drawer side raises ValueError.""" layout = Layout(root_instance, app_name="Test App") with pytest.raises(ValueError, match="Invalid drawer side"): layout.update_drawer_width("invalid", 250) def test_layout_state_has_correct_defaults(self, root_instance): """Test that LayoutState initializes with correct default values.""" layout = Layout(root_instance, app_name="Test App") state = layout._state assert state.left_drawer_open is True assert state.right_drawer_open is True assert state.left_drawer_width == 250 assert state.right_drawer_width == 250 def test_layout_is_single_instance(self, root_instance): """Test that Layout behaves as SingleInstance (same ID returns same instance).""" layout1 = Layout(root_instance, app_name="Test App", _id="my_layout") layout2 = Layout(root_instance, app_name="Test App", _id="my_layout") # Should be the same instance assert layout1 is layout2 def test_commands_are_created(self, root_instance): """Test that Layout creates necessary commands.""" layout = Layout(root_instance, app_name="Test App") # Test toggle commands left_toggle_cmd = layout.commands.toggle_drawer("left") assert left_toggle_cmd is not None assert left_toggle_cmd.id is not None right_toggle_cmd = layout.commands.toggle_drawer("right") assert right_toggle_cmd is not None assert right_toggle_cmd.id is not None # Test width update commands left_width_cmd = layout.commands.update_drawer_width("left") assert left_width_cmd is not None assert left_width_cmd.id is not None right_width_cmd = layout.commands.update_drawer_width("right") assert right_width_cmd is not None assert right_width_cmd.id is not None class TestLayoutRender: """Tests for Layout HTML rendering.""" def test_empty_layout_is_rendered(self, root_instance): """Test that Layout renders with all main structural sections. Why these elements matter: - 6 children: Verifies all main sections are rendered (header, drawers, main, footer, script) - _id: Essential for layout identification and resizer initialization - cls="mf-layout": Root CSS class for layout styling """ layout = Layout(root_instance, app_name="Test App") expected = Div( Header(), Div(), Main(), Div(), Footer(), Script(), _id=layout._id, cls="mf-layout" ) assert matches(layout.render(), expected) def test_header_with_drawer_icons_is_rendered(self, root_instance): """Test that header renders with drawer toggle icons. Why these elements matter: - 2 Div children: Left/right header structure for organizing controls - Svg: Toggle icon is essential for user interaction with drawer - TestObject(UserProfile): UserProfile component must be present in header - cls="flex gap-1": CSS critical for horizontal alignment of header items - cls="mf-layout-header": Root header class for styling """ layout = Layout(root_instance, app_name="Test App") header = layout._mk_header() expected = Header( Div( Div(NotStr(name="panel_right_expand20_regular")), cls="flex gap-1" ), Div( TestObject(UserProfile), cls="flex gap-1" ), cls="mf-layout-header" ) assert matches(header, expected) def test_footer_is_rendered(self, root_instance): """Test that footer renders with correct structure. Why these elements matter: - cls Contains "mf-layout-footer": Root footer class for styling - cls Contains "footer": DaisyUI base footer class """ layout = Layout(root_instance, app_name="Test App") footer = layout._mk_footer() expected = Footer( cls=Contains("mf-layout-footer") ) assert matches(footer, expected) def test_main_content_is_rendered(self, root_instance): """Test that main content area renders correctly. Why these elements matter: - cls="mf-layout-main": Root main class for styling """ layout = Layout(root_instance, app_name="Test App") main = layout._mk_main() expected = Main( cls="mf-layout-main" ) assert matches(main, expected) def test_left_drawer_is_rendered_when_open(self, root_instance): """Test that left drawer renders with correct classes when open. Why these elements matter: - _id: Required for targeting drawer in HTMX updates - cls Contains "mf-layout-drawer": Base drawer class for styling - cls Contains "mf-layout-left-drawer": Left-specific drawer positioning - style Contains width: Drawer width must be applied for sizing """ layout = Layout(root_instance, app_name="Test App") drawer = layout._mk_left_drawer() expected = Div( _id=f"{layout._id}_ld", cls=Contains("mf-layout-drawer"), #cls=Contains("mf-layout-left-drawer"), style=Contains("width: 250px") ) assert matches(drawer, expected) def test_left_drawer_has_collapsed_class_when_closed(self, root_instance): """Test that left drawer renders with collapsed class when closed. Why these elements matter: - _id: Required for targeting drawer in HTMX updates - cls Contains "collapsed": Class triggers CSS hiding animation - style Contains "width: 0px": Zero width is crucial for collapse animation """ layout = Layout(root_instance, app_name="Test App") layout._state.left_drawer_open = False drawer = layout._mk_left_drawer() expected = Div( _id=f"{layout._id}_ld", cls=Contains("collapsed"), style=Contains("width: 0px") ) assert matches(drawer, expected) def test_right_drawer_is_rendered_when_open(self, root_instance): """Test that right drawer renders with correct classes when open. Why these elements matter: - _id: Required for targeting drawer in HTMX updates - cls Contains "mf-layout-drawer": Base drawer class for styling - cls Contains "mf-layout-right-drawer": Right-specific drawer positioning - style Contains width: Drawer width must be applied for sizing """ layout = Layout(root_instance, app_name="Test App") drawer = layout._mk_right_drawer() expected = Div( _id=f"{layout._id}_rd", cls=Contains("mf-layout-drawer"), #cls=Contains("mf-layout-right-drawer"), style=Contains("width: 250px") ) assert matches(drawer, expected) def test_right_drawer_has_collapsed_class_when_closed(self, root_instance): """Test that right drawer renders with collapsed class when closed. Why these elements matter: - _id: Required for targeting drawer in HTMX updates - cls Contains "collapsed": Class triggers CSS hiding animation - style Contains "width: 0px": Zero width is crucial for collapse animation """ layout = Layout(root_instance, app_name="Test App") layout._state.right_drawer_open = False drawer = layout._mk_right_drawer() expected = Div( _id=f"{layout._id}_rd", cls=Contains("collapsed"), style=Contains("width: 0px") ) assert matches(drawer, expected) def test_drawer_width_is_applied_as_style(self, root_instance): """Test that custom drawer width is applied as inline style. Why this test matters: - style Contains "width: 300px": Verifies that width updates are reflected in style attribute """ layout = Layout(root_instance, app_name="Test App") layout._state.left_drawer_width = 300 drawer = layout._mk_left_drawer() expected = Div( style=Contains("width: 300px") ) assert matches(drawer, expected) def test_left_drawer_has_resizer_element(self, root_instance): """Test that left drawer contains resizer element. Why this test matters: - Resizer element must be present for drawer width adjustment - cls "mf-resizer-left": Left-specific resizer for correct edge positioning """ layout = Layout(root_instance, app_name="Test App") drawer = layout._mk_left_drawer() resizers = find(drawer, Div(cls=Contains("mf-resizer-left"))) assert len(resizers) == 1, "Left drawer should contain exactly one resizer element" def test_right_drawer_has_resizer_element(self, root_instance): """Test that right drawer contains resizer element. Why this test matters: - Resizer element must be present for drawer width adjustment - cls "mf-resizer-right": Right-specific resizer for correct edge positioning """ layout = Layout(root_instance, app_name="Test App") drawer = layout._mk_right_drawer() resizers = find(drawer, Div(cls=Contains("mf-resizer-right"))) assert len(resizers) == 1, "Right drawer should contain exactly one resizer element" def test_drawer_groups_are_separated_by_dividers(self, root_instance): """Test that multiple groups in drawer are separated by divider elements. Why this test matters: - Dividers provide visual separation between content groups - At least one divider should exist when multiple groups are present """ layout = Layout(root_instance, app_name="Test App") layout.left_drawer.add(Div("Item 1"), group="group1") layout.left_drawer.add(Div("Item 2"), group="group2") drawer = layout._mk_left_drawer() content_wrappers = find(drawer, Div(cls="mf-layout-drawer-content")) assert len(content_wrappers) == 1 content = content_wrappers[0] dividers = find(content, Div(cls="divider")) assert len(dividers) >= 1, "Groups should be separated by dividers" def test_resizer_script_is_included(self, root_instance): """Test that resizer initialization script is included in render. Why this test matters: - Script element: Required to initialize resizer functionality - Script contains initResizer call: Ensures resizer is activated for this layout instance """ layout = Layout(root_instance, app_name="Test App") rendered = layout.render() scripts = find(rendered, Script()) assert len(scripts) == 1, "Layout should contain exactly one script element" script_content = str(scripts[0].children[0]) assert f"initResizer('{layout._id}')" in script_content, "Script must initialize resizer with layout ID"