Testing Layout

This commit is contained in:
2025-11-29 23:47:11 +01:00
parent 1be75263ad
commit 96ed447eae
3 changed files with 568 additions and 2 deletions

View File

@@ -199,7 +199,91 @@ class TestControlRender:
**Note:** This organization applies **only to controls** (components with rendering capabilities). For other classes (core logic, utilities, etc.), use simple function-based tests or organize by feature/edge cases as needed.
### UTR-11: Test Workflow
### UTR-11: Required Reading for Control Render Tests
**Before writing ANY render tests for Controls, you MUST:**
1. **Read the matcher documentation**: `docs/testing_rendered_components.md`
2. **Understand the key concepts**:
- How `matches()` and `find()` work
- When to use predicates (Contains, StartsWith, AnyValue, etc.)
- How to test only what matters (not every detail)
- How to read error messages with `^^^` markers
3. **Apply the best practices**:
- Test only important structural elements and attributes
- Use predicates for dynamic/generated values
- Don't over-specify tests with irrelevant details
- Structure tests in layers (overall structure, then details)
**Mandatory render test rules:**
1. **Test naming**: Use descriptive names like `test_empty_layout_is_rendered()` not `test_layout_renders_with_all_sections()`
2. **Documentation format**: Every render test MUST have a docstring with:
- First line: Brief description of what is being tested
- Blank line
- "Why these elements matter:" or "Why this test matters:" section
- List of important elements/attributes being tested with explanations (in English)
3. **No inline comments**: Do NOT add comments on each line of the expected structure
4. **Icon testing**: Use `Div(NotStr(name="icon_name"))` to test SVG icons
- Use the exact icon name from the import (e.g., `name="panel_right_expand20_regular"`)
- Use `name=""` (empty string) if the import is not explicit
- NEVER use `name="svg"` - it will cause test failures
5. **Component testing**: Use `TestObject(ComponentClass)` to test presence of components
6. **Explanation focus**: In "Why these elements matter", refer to the logical element (e.g., "Svg") not the technical implementation (e.g., "Div(NotStr(...))")
**Example of proper render test:**
```python
from myfasthtml.test.matcher import matches, find, Contains, NotStr, TestObject
from fasthtml.common import Div, Header, Button
class TestControlRender:
def test_empty_control_is_rendered(self, root_instance):
"""Test that control renders with main structural sections.
Why these elements matter:
- 3 children: Verifies header, body, and footer are all rendered
- _id: Essential for HTMX targeting and component identification
- cls="control-wrapper": Root CSS class for styling
"""
control = MyControl(root_instance)
expected = Div(
Header(),
Div(),
Div(),
_id=control._id,
cls="control-wrapper"
)
assert matches(control.render(), expected)
def test_header_with_icon_is_rendered(self, root_instance):
"""Test that header renders with action icon.
Why these elements matter:
- Svg: Action icon is essential for user interaction
- TestObject(ActionButton): ActionButton component must be present
- cls="header-bar": Required CSS class for header styling
"""
control = MyControl(root_instance)
header = control._mk_header()
expected = Header(
Div(NotStr(name="action_icon_20_regular")),
TestObject(ActionButton),
cls="header-bar"
)
assert matches(header, expected)
```
**When proposing render tests:**
- Reference specific patterns from the documentation
- Explain why you chose to test certain elements and not others
- Justify the use of predicates vs exact values
- Always include "Why these elements matter" documentation
### UTR-12: Test Workflow
1. **Receive code to test** - User provides file path or code section
2. **Check existing tests** - Look for corresponding test file and read it if it exists

View File

@@ -0,0 +1,482 @@
"""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"

View File

@@ -381,7 +381,7 @@ class TestTreeviewBehaviour:
class TestTreeViewRender:
"""Tests for TreeView HTML rendering."""
def test_i_can_render_empty_treeview(self, root_instance):
def test_empty_treeview_is_rendered(self, root_instance):
"""Test that TreeView generates correct HTML structure."""
tree_view = TreeView(root_instance)
expected = Div(