From 96ed447eae9b5d744655c28e36615e22744f8ea8 Mon Sep 17 00:00:00 2001 From: Kodjo Sossouvi Date: Sat, 29 Nov 2025 23:47:11 +0100 Subject: [PATCH] Testing Layout --- .claude/commands/unit-tester.md | 86 +++++- tests/controls/test_layout.py | 482 ++++++++++++++++++++++++++++++++ tests/controls/test_treeview.py | 2 +- 3 files changed, 568 insertions(+), 2 deletions(-) create mode 100644 tests/controls/test_layout.py diff --git a/.claude/commands/unit-tester.md b/.claude/commands/unit-tester.md index 753548a..026846d 100644 --- a/.claude/commands/unit-tester.md +++ b/.claude/commands/unit-tester.md @@ -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 diff --git a/tests/controls/test_layout.py b/tests/controls/test_layout.py new file mode 100644 index 0000000..e359021 --- /dev/null +++ b/tests/controls/test_layout.py @@ -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" diff --git a/tests/controls/test_treeview.py b/tests/controls/test_treeview.py index 3dadd81..5fa95c3 100644 --- a/tests/controls/test_treeview.py +++ b/tests/controls/test_treeview.py @@ -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(