483 lines
17 KiB
Python
483 lines
17 KiB
Python
"""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"
|