"""Unit tests for Layout component.""" import shutil import pytest from fasthtml.components import * from myfasthtml.controls.Layout import Layout from myfasthtml.controls.UserProfile import UserProfile from myfasthtml.test.matcher import matches, find, Contains, find_one, TestIcon, TestScript, TestObject, AnyValue, Skip, \ TestIconNotStr 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: - 7 children: Verifies all main sections are rendered (tooltip container, header, drawers, main, footer, script) - _id: Essential for layout identification and resizer initialization - cls="mf-layout": Root CSS class for layout styling """ expected = Div( Div(), # tooltip container 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("PanelLeftContract20Regular"), 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("mf-layout-drawer", "mf-layout-left-drawer", "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("mf-layout-drawer", "mf-layout-right-drawer", "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_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 initLayout call: Ensures layout is activated for this layout instance """ script = find_one(layout.render(), Script()) expected = TestScript(f"initLayout('{layout._id}');") assert matches(script, expected) def test_left_drawer_renders_content_with_groups(self, layout): """Test that left drawer renders content organized by groups with proper wrappers. Why these elements matter: - mf-layout-drawer-content wrapper: Required container for drawer scrolling behavior - divider elements: Visual separation between content groups - Group count validation: Ensures all added groups are rendered """ layout.left_drawer.add(Div("Item 1", id="item1"), group="group1") layout.left_drawer.add(Div("Item 2", id="item2"), group="group2") drawer = find_one(layout.render(), Div(id=f"{layout._id}_ld")) content_wrappers = find(drawer, Div(cls="mf-layout-drawer-content")) assert len(content_wrappers) == 1, "Left drawer should contain exactly one content wrapper" content = content_wrappers[0] dividers = find(content, Div(cls="divider")) assert len(dividers) == 1, "Two groups should be separated by exactly one divider" def test_header_left_renders_custom_content(self, layout): """Test that custom content added to header_left is rendered in the left header section. Why these elements matter: - id="{layout._id}_hl": Essential for HTMX targeting during updates - cls Contains "flex": Ensures horizontal layout of header items - Icon presence: Toggle drawer icon must always be first element - Custom content: Verifies header_left.add() correctly renders content """ custom_content = Div("Custom Header", id="custom_header") layout.header_left.add(custom_content) header_left = find_one(layout.render(), Div(id=f"{layout._id}_hl")) expected = Div( TestIcon(""), Skip(None), Div("Custom Header", id="custom_header"), id=f"{layout._id}_hl", cls=Contains("flex", "gap-1") ) assert matches(header_left, expected) def test_header_right_renders_custom_content(self, layout): """Test that custom content added to header_right is rendered in the right header section. Why these elements matter: - id="{layout._id}_hr": Essential for HTMX targeting during updates - cls Contains "flex": Ensures horizontal layout of header items - Custom content: Verifies header_right.add() correctly renders content - UserProfile component: Must always be last element in right header """ custom_content = Div("Custom Header Right", id="custom_header_right") layout.header_right.add(custom_content) header_right = find_one(layout.render(), Div(id=f"{layout._id}_hr")) expected = Div( Skip(None), Div("Custom Header Right", id="custom_header_right"), TestObject(UserProfile), id=f"{layout._id}_hr", cls=Contains("flex", "gap-1") ) assert matches(header_right, expected) def test_footer_left_renders_custom_content(self, layout): """Test that custom content added to footer_left is rendered in the left footer section. Why these elements matter: - id="{layout._id}_fl": Essential for HTMX targeting during updates - cls Contains "flex": Ensures horizontal layout of footer items - Custom content: Verifies footer_left.add() correctly renders content """ custom_content = Div("Custom Footer Left", id="custom_footer_left") layout.footer_left.add(custom_content) footer_left = find_one(layout.render(), Div(id=f"{layout._id}_fl")) expected = Div( Skip(None), Div("Custom Footer Left", id="custom_footer_left"), id=f"{layout._id}_fl", cls=Contains("flex", "gap-1") ) assert matches(footer_left, expected) def test_footer_right_renders_custom_content(self, layout): """Test that custom content added to footer_right is rendered in the right footer section. Why these elements matter: - id="{layout._id}_fr": Essential for HTMX targeting during updates - cls Contains "flex": Ensures horizontal layout of footer items - Custom content: Verifies footer_right.add() correctly renders content """ custom_content = Div("Custom Footer Right", id="custom_footer_right") layout.footer_right.add(custom_content) footer_right = find_one(layout.render(), Div(id=f"{layout._id}_fr")) expected = Div( Skip(None), Div("Custom Footer Right", id="custom_footer_right"), id=f"{layout._id}_fr", cls=Contains("flex", "gap-1") ) assert matches(footer_right, expected) def test_left_drawer_resizer_has_command_data(self, layout): """Test that left drawer resizer has correct data attributes for command binding. Why these elements matter: - data_command_id: JavaScript uses this to trigger width update command - data_side="left": JavaScript needs this to identify which drawer to resize - cls Contains "mf-resizer-left": CSS uses this for left-specific positioning """ drawer = find_one(layout.render(), Div(id=f"{layout._id}_ld")) resizer = find_one(drawer, Div(cls=Contains("mf-resizer-left"))) expected = Div( cls=Contains("mf-resizer", "mf-resizer-left"), data_command_id=AnyValue(), data_side="left" ) assert matches(resizer, expected) def test_right_drawer_resizer_has_command_data(self, layout): """Test that right drawer resizer has correct data attributes for command binding. Why these elements matter: - data_command_id: JavaScript uses this to trigger width update command - data_side="right": JavaScript needs this to identify which drawer to resize - cls Contains "mf-resizer-right": CSS uses this for right-specific positioning """ drawer = find_one(layout.render(), Div(id=f"{layout._id}_rd")) resizer = find_one(drawer, Div(cls=Contains("mf-resizer-right"))) expected = Div( cls=Contains("mf-resizer", "mf-resizer-right"), data_command_id=AnyValue(), data_side="right" ) assert matches(resizer, expected) def test_left_drawer_icon_changes_when_closed(self, layout): """Test that left drawer toggle icon changes from expand to collapse when drawer is closed. Why these elements matter: - id="{layout._id}_ldi": Required for HTMX swap-oob updates when toggling - Icon type: Visual feedback to user about drawer state (expand icon when closed) - Icon change: Validates that toggle_drawer returns correct icon """ layout._state.left_drawer_open = False icon_div = find_one(layout.render(), Div(id=f"{layout._id}_ldi")) expected = Div( TestIconNotStr("panel_left_expand20_regular"), id=f"{layout._id}_ldi" ) assert matches(icon_div, expected) def test_left_drawer_icon_changes_when_opne(self, layout): """Test that left drawer toggle icon changes from collapse to expand when drawer is open.. Why these elements matter: - id="{layout._id}_ldi": Required for HTMX swap-oob updates when toggling - Icon type: Visual feedback to user about drawer state (expand icon when closed) - Icon change: Validates that toggle_drawer returns correct icon """ layout._state.left_drawer_open = True icon_div = find_one(layout.render(), Div(id=f"{layout._id}_ldi")) expected = Div( TestIconNotStr("panel_left_contract20_regular"), id=f"{layout._id}_ldi" ) assert matches(icon_div, expected) def test_tooltip_container_is_rendered(self, layout): """Test that tooltip container is rendered at the top of the layout. Why these elements matter: - id="tt_{layout._id}": JavaScript uses this to append dynamically created tooltips - cls Contains "mf-tooltip-container": CSS positioning for tooltip overlay layer - Presence verification: Tooltips won't work if container is missing """ tooltip_container = find_one(layout.render(), Div(id=f"tt_{layout._id}")) expected = Div( id=f"tt_{layout._id}", cls=Contains("mf-tooltip-container") ) assert matches(tooltip_container, expected) def test_header_right_contains_user_profile(self, layout): """Test that UserProfile component is rendered in the right header section. Why these elements matter: - UserProfile component: Provides authentication and user menu functionality - Position in header right: Conventional placement for user profile controls - Count verification: Ensures component is not duplicated """ header_right = find_one(layout.render(), Div(id=f"{layout._id}_hr")) user_profiles = find(header_right, TestObject(UserProfile)) assert len(user_profiles) == 1, "Header right should contain exactly one UserProfile component" def test_layout_initialization_script_is_included(self, layout): """Test that layout initialization script is included in render output. Why these elements matter: - Script presence: Required to initialize layout behavior (resizers, drawers) - initLayout() call: Activates JavaScript functionality for this layout instance - Layout ID parameter: Ensures initialization targets correct layout """ script = find_one(layout.render(), Script()) expected = TestScript(f"initLayout('{layout._id}');") assert matches(script, expected)