"""Unit tests for Layout component.""" import shutil import pytest from fasthtml.components import * from myfasthtml.controls.Layout import Layout from myfasthtml.test.matcher import matches, find, Contains, find_one, TestIcon, TestScript 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_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.""" @pytest.fixture def layout(self, root_instance): return Layout(root_instance, app_name="Test App") def test_empty_layout_is_rendered(self, layout): """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 """ expected = Div( Header(), Div(), # left drawer Main(), Div(), # right drawer Footer(), Script(), _id=layout._id, cls="mf-layout" ) assert matches(layout.render(), expected) def test_header_has_two_sides(self, layout): """Test that there is a left and right header section.""" header = find_one(layout.render(), Header(cls="mf-layout-header")) expected = Header( Div(id=f"{layout._id}_hl"), Div(id=f"{layout._id}_hr"), ) assert matches(header, expected) def test_footer_has_two_sides(self, layout): """Test that there is a left and right footer section.""" footer = find_one(layout.render(), Footer(cls=Contains("mf-layout-footer"))) expected = Footer( Div(id=f"{layout._id}_fl"), Div(id=f"{layout._id}_fr"), ) assert matches(footer, expected) def test_header_with_drawer_icons_is_rendered(self, layout): """Test that header renders with drawer toggle icons. Why these elements matter: - Only the first div is required to test the presence of the icon - Use TestIcon to test the existence of an icon """ header = find_one(layout.render(), Header(cls="mf-layout-header")) expected = Header( Div( TestIcon("panel_right_expand20_regular"), cls="flex gap-1" ), cls="mf-layout-header" ) assert matches(header, expected) def test_main_content_is_rendered_with_some_element(self, layout): """Test that main content area renders correctly. Why these elements matter: - cls="mf-layout-main": Root main class for styling """ layout.set_main(Div("Main content")) main = find_one(layout.render(), Main(cls="mf-layout-main")) expected = Main( Div("Main content"), cls="mf-layout-main" ) assert matches(main, expected) def test_left_drawer_is_rendered_when_open(self, layout): """Test that left drawer renders with correct classes when open. Why these elements matter: - _id: Required for targeting drawer in HTMX updates. search by id whenever possible - 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._state.left_drawer_open = True drawer = find_one(layout.render(), Div(id=f"{layout._id}_ld")) expected = Div( _id=f"{layout._id}_ld", cls=Contains("mf-layout-drawer", "mf-layout-left-drawer"), style=Contains("width: 250px") ) assert matches(drawer, expected) def test_left_drawer_has_collapsed_class_when_closed(self, layout): """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._state.left_drawer_open = False drawer = find_one(layout.render(), Div(id=f"{layout._id}_ld")) 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, layout): """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._state.right_drawer_open = True drawer = find_one(layout.render(), Div(id=f"{layout._id}_rd")) expected = Div( _id=f"{layout._id}_rd", cls=Contains("mf-layout-drawer", "mf-layout-right-drawer"), style=Contains("width: 250px") ) assert matches(drawer, expected) def test_right_drawer_has_collapsed_class_when_closed(self, layout): """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._state.right_drawer_open = False drawer = find_one(layout.render(), Div(id=f"{layout._id}_rd")) 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, layout): """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._state.left_drawer_width = 300 drawer = find_one(layout.render(), Div(id=f"{layout._id}_ld")) expected = Div( style=Contains("width: 300px") ) assert matches(drawer, expected) def test_left_drawer_has_resizer_element(self, layout): """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 """ drawer = find(layout.render(), Div(id=f"{layout._id}_ld")) 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, layout): """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 """ drawer = find(layout.render(), Div(id=f"{layout._id}_rd")) 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, layout): """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.left_drawer.add(Div("Item 1"), group="group1") layout.left_drawer.add(Div("Item 2"), group="group2") drawer = find(layout.render(), Div(id=f"{layout._id}_ld")) 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, layout): """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 """ script = find_one(layout.render(), Script()) expected = TestScript(f"initResizer('{layout._id}');") assert matches(script, expected)