Added Controls testing + documentation

This commit is contained in:
2025-12-05 19:17:21 +01:00
parent 1d20fb8650
commit 8e5fa7f752
37 changed files with 4868 additions and 363 deletions

View File

@@ -1,6 +1,6 @@
import pytest
from myfasthtml.core.instances import SingleInstance
from myfasthtml.core.instances import SingleInstance, InstancesManager
class RootInstanceForTests(SingleInstance):
@@ -25,4 +25,5 @@ def session():
@pytest.fixture(scope="session")
def root_instance(session):
InstancesManager.reset()
return RootInstanceForTests(session=session)

View File

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

View File

@@ -6,7 +6,8 @@ from fasthtml.components import *
from myfasthtml.controls.Keyboard import Keyboard
from myfasthtml.controls.TreeView import TreeView, TreeNode
from myfasthtml.test.matcher import matches, TestObject, TestCommand
from myfasthtml.test.matcher import matches, TestObject, TestCommand, TestIcon, find_one, find, Contains, \
DoesNotContain
from .conftest import root_instance
@@ -376,14 +377,37 @@ class TestTreeviewBehaviour:
# Try to add sibling to node that doesn't exist
with pytest.raises(ValueError, match="Node.*does not exist"):
tree_view._add_sibling("nonexistent_id")
def test_i_can_initialize_with_items_dict(self, root_instance):
"""Test that TreeView can be initialized with a dictionary of items."""
node1 = TreeNode(label="Node 1", type="folder")
node2 = TreeNode(label="Node 2", type="file")
items = {node1.id: node1, node2.id: node2}
tree_view = TreeView(root_instance, items=items)
assert len(tree_view._state.items) == 2
assert tree_view._state.items[node1.id].label == "Node 1"
assert tree_view._state.items[node1.id].type == "folder"
assert tree_view._state.items[node2.id].label == "Node 2"
assert tree_view._state.items[node2.id].type == "file"
class TestTreeViewRender:
"""Tests for TreeView HTML rendering."""
def test_i_can_render_empty_treeview(self, root_instance):
"""Test that TreeView generates correct HTML structure."""
tree_view = TreeView(root_instance)
@pytest.fixture
def tree_view(self, root_instance):
return TreeView(root_instance)
def test_empty_treeview_is_rendered(self, tree_view):
"""Test that empty TreeView generates correct HTML structure.
Why these elements matter:
- TestObject Keyboard: Essential for keyboard shortcuts (Escape to cancel rename)
- _id: Required for HTMX targeting and component identification
- cls "mf-treeview": Root CSS class for TreeView styling
"""
expected = Div(
TestObject(Keyboard, combinations={"esc": TestCommand("CancelRename")}),
_id=tree_view.get_id(),
@@ -392,7 +416,445 @@ class TestTreeViewRender:
assert matches(tree_view.__ft__(), expected)
def test_node_action_buttons_are_rendered(self):
"""Test that action buttons are present in rendered HTML."""
# Signature only - implementation later
pass
def test_node_with_children_collapsed_is_rendered(self, tree_view):
"""Test that a collapsed node with children renders correctly.
Why these elements matter:
- TestIcon chevron_right: Indicates visually that the node is collapsed
- Span with label: Displays the node's text content
- Action buttons (add_child, edit, delete): Enable user interactions
- cls "mf-treenode": Required CSS class for node styling
- data_node_id: Essential for identifying the node in DOM operations
- No children in container: Verifies children are hidden when collapsed
"""
parent = TreeNode(label="Parent", type="folder")
child = TreeNode(label="Child", type="file")
tree_view.add_node(parent)
tree_view.add_node(child, parent_id=parent.id)
# Step 1: Extract the node element to test
rendered = tree_view.render()
# Step 2: Define expected structure
expected = Div(
Div(
Div(
TestIcon("chevron_right20_regular"), # Collapsed toggle icon
Span("Parent"), # Label
Div( # Action buttons
TestIcon("add_circle20_regular"),
TestIcon("edit20_regular"),
TestIcon("delete20_regular"),
cls=Contains("mf-treenode-actions")
),
cls=Contains("mf-treenode"),
),
cls="mf-treenode-container",
data_node_id=parent.id
),
id=tree_view.get_id()
)
# Step 3: Compare
assert matches(rendered, expected)
# Verify no children are rendered (collapsed)
child_containers = find(rendered, Div(data_node_id=parent.id))
assert len(child_containers) == 1, "Children should not be rendered when node is collapsed"
def test_node_with_children_expanded_is_rendered(self, tree_view):
"""Test that an expanded node with children renders correctly.
Why these elements matter:
- TestIcon chevron_down: Indicates visually that the node is expanded
- Children rendered: Verifies that child nodes are visible when parent is expanded
- Child has its own node structure: Ensures recursive rendering works correctly
Rendered Structure :
Div (node_container with data_node_id)
├─ Div (information on current node - icon, label, actions)
└─ Div* (children - recursive containers, only if expanded)
"""
parent = TreeNode(label="Parent", type="folder")
child1 = TreeNode(label="Child1", type="file")
child2 = TreeNode(label="Child2", type="file")
tree_view.add_node(parent)
tree_view.add_node(child1, parent_id=parent.id)
tree_view.add_node(child2, parent_id=parent.id)
tree_view._toggle_node(parent.id) # Expand the parent
# Step 1: Extract the parent node element to test
rendered = tree_view.render()
parent_container = find_one(rendered, Div(data_node_id=parent.id))
expected = Div(
Div(), # parent info (see test_node_with_children_collapsed_is_rendered)
Div(data_node_id=child1.id),
Div(data_node_id=child2.id),
)
# Step 3: Compare
assert matches(parent_container, expected)
# now check the child node structure
child_container = find_one(rendered, Div(data_node_id=child1.id))
expected_child_container = Div(
Div(
Div(None), # No icon, the div is empty
Span("Child1"),
Div(), # action buttons
cls=Contains("mf-treenode")
),
cls="mf-treenode-container",
data_node_id=child1.id,
)
assert matches(child_container, expected_child_container)
def test_leaf_node_is_rendered(self, tree_view):
"""Test that a leaf node (no children) renders without toggle icon.
Why these elements matter:
- No toggle icon (or empty space): Leaf nodes don't need expand/collapse functionality
- Span with label: Displays the node's text content
- Action buttons present: Even leaf nodes can be edited, deleted, or receive children
"""
leaf = TreeNode(label="Leaf Node", type="file")
tree_view.add_node(leaf)
# Step 1: Extract the leaf node element to test
rendered = tree_view.render()
leaf_container = find_one(rendered, Div(data_node_id=leaf.id))
# Step 2: Define expected structure
expected = Div(
Div(
Div(None), # No icon, the div is empty
Span("Leaf Node"), # Label
Div(), # Action buttons still present
),
cls=Contains("mf-treenode"),
data_node_id=leaf.id
)
# Step 3: Compare
assert matches(leaf_container, expected)
def test_selected_node_has_selected_class(self, tree_view):
"""Test that a selected node has the 'selected' CSS class.
Why these elements matter:
- cls Contains "selected": Enables visual highlighting of the selected node
- Div with mf-treenode: The node information container with selected class
- data_node_id: Required for identifying which node is selected
"""
node = TreeNode(label="Selected Node", type="file")
tree_view.add_node(node)
tree_view._select_node(node.id)
rendered = tree_view.render()
selected_container = find_one(rendered, Div(data_node_id=node.id))
expected = Div(
Div(
Div(None), # No icon, leaf node
Span("Selected Node"),
Div(), # Action buttons
cls=Contains("mf-treenode", "selected")
),
cls="mf-treenode-container",
data_node_id=node.id
)
assert matches(selected_container, expected)
def test_node_in_editing_mode_shows_input(self, tree_view):
"""Test that a node in editing mode renders an Input instead of Span.
Why these elements matter:
- Input element: Enables user to modify the node label inline
- cls "mf-treenode-input": Required CSS class for input field styling
- name "node_label": Essential for form data submission
- value with current label: Pre-fills the input with existing text
- cls does NOT contain "selected": Avoids double highlighting during editing
"""
node = TreeNode(label="Edit Me", type="file")
tree_view.add_node(node)
tree_view._start_rename(node.id)
rendered = tree_view.render()
editing_container = find_one(rendered, Div(data_node_id=node.id))
expected = Div(
Div(
Div(None), # No icon, leaf node
Input(
name="node_label",
value="Edit Me",
cls=Contains("mf-treenode-input")
),
Div(), # Action buttons
cls=Contains("mf-treenode")
),
cls="mf-treenode-container",
data_node_id=node.id
)
assert matches(editing_container, expected)
# Verify "selected" class is NOT present
editing_node_info = find_one(editing_container, Div(cls=Contains("mf-treenode", _word=True)))
no_selected = Div(
cls=DoesNotContain("selected")
)
assert matches(editing_node_info, no_selected)
def test_node_indentation_increases_with_level(self, tree_view):
"""Test that node indentation increases correctly with hierarchy level.
Why these elements matter:
- style Contains "padding-left: 0px": Root node has no indentation
- style Contains "padding-left: 20px": Child is indented by 20px
- style Contains "padding-left: 40px": Grandchild is indented by 40px
- Progressive padding: Creates the visual hierarchy of the tree structure
- Padding is applied to the node info Div, not the container
"""
root = TreeNode(label="Root", type="folder")
child = TreeNode(label="Child", type="folder")
grandchild = TreeNode(label="Grandchild", type="file")
tree_view.add_node(root)
tree_view.add_node(child, parent_id=root.id)
tree_view.add_node(grandchild, parent_id=child.id)
# Expand all to make hierarchy visible
tree_view._toggle_node(root.id)
tree_view._toggle_node(child.id)
rendered = tree_view.render()
# Test root node (level 0)
root_container = find_one(rendered, Div(data_node_id=root.id))
root_expected = Div(
Div(
TestIcon("chevron_down20_regular"), # Expanded icon
Span("Root"),
Div(), # Action buttons
cls=Contains("mf-treenode"),
style=Contains("padding-left: 0px")
),
cls="mf-treenode-container",
data_node_id=root.id
)
assert matches(root_container, root_expected)
# Test child node (level 1)
child_container = find_one(rendered, Div(data_node_id=child.id))
child_expected = Div(
Div(
TestIcon("chevron_down20_regular"), # Expanded icon
Span("Child"),
Div(), # Action buttons
cls=Contains("mf-treenode"),
style=Contains("padding-left: 20px")
),
cls="mf-treenode-container",
data_node_id=child.id
)
assert matches(child_container, child_expected)
# Test grandchild node (level 2)
grandchild_container = find_one(rendered, Div(data_node_id=grandchild.id))
grandchild_expected = Div(
Div(
Div(None), # No icon, leaf node
Span("Grandchild"),
Div(), # Action buttons
cls=Contains("mf-treenode"),
style=Contains("padding-left: 40px")
),
cls="mf-treenode-container",
data_node_id=grandchild.id
)
assert matches(grandchild_container, grandchild_expected)
@pytest.mark.skip(reason="Not possible to validate if the command unicity is not fixed")
def test_toggle_icon_has_correct_command(self, tree_view):
"""Test that toggle icon has ToggleNode command.
Why these elements matter:
- Div wrapper with command: mk.icon() wraps SVG in Div with HTMX attributes
- TestIcon inside Div: Verifies correct chevron icon is displayed
- TestCommand "ToggleNode": Essential for HTMX to route to correct handler
- Command targets correct node_id: Ensures the right node is toggled
"""
parent = TreeNode(label="Parent", type="folder")
child = TreeNode(label="Child", type="file")
tree_view.add_node(parent)
tree_view.add_node(child, parent_id=parent.id)
# Step 1: Extract the parent node element
rendered = tree_view.render()
parent_node = find_one(rendered, Div(data_node_id=parent.id))
# Step 2: Define expected structure
expected = Div(
Div(
TestIcon("chevron_right20_regular", command=tree_view.commands.toggle_node(parent.id)),
),
data_node_id=parent.id
)
# Step 3: Compare
assert matches(parent_node, expected)
@pytest.mark.skip(reason="Not possible to validate if the command unicity is not fixed")
def test_action_buttons_have_correct_commands(self, tree_view):
"""Test that action buttons have correct commands.
Why these elements matter:
- add_circle icon with AddChild: Enables adding child nodes via HTMX
- edit icon with StartRename: Triggers inline editing mode
- delete icon with DeleteNode: Enables node deletion
- cls "mf-treenode-actions": Required CSS class for button container styling
"""
node = TreeNode(label="Node", type="folder")
tree_view.add_node(node)
# Step 1: Extract the action buttons container
rendered = tree_view.render()
actions = find_one(rendered, Div(cls=Contains("mf-treenode-actions")))
# Step 2: Define expected structure
expected = Div(
TestIcon("add_circle20_regular", command=tree_view.commands.add_child(node.id)),
TestIcon("edit20_regular", command=tree_view.commands.start_rename(node.id)),
TestIcon("delete20_regular", command=tree_view.commands.delete_node(node.id)),
cls=Contains("mf-treenode-actions")
)
# Step 3: Compare
assert matches(actions, expected)
@pytest.mark.skip(reason="Not possible to validate if the command unicity is not fixed")
def test_label_has_select_command(self, tree_view):
"""Test that node label has SelectNode command.
Why these elements matter:
- Span with node label: Displays the node text
- TestCommand "SelectNode": Clicking label selects the node via HTMX
- cls "mf-treenode-label": Required CSS class for label styling
"""
node = TreeNode(label="Clickable Node", type="file")
tree_view.add_node(node)
# Step 1: Extract the label element
rendered = tree_view.render()
label = find_one(rendered, Span(cls=Contains("mf-treenode-label")))
# Step 2: Define expected structure
expected = Span(
"Clickable Node",
command=tree_view.commands.select_node(node.id),
cls=Contains("mf-treenode-label")
)
# Step 3: Compare
assert matches(label, expected)
@pytest.mark.skip(reason="Not possible to validate if the command unicity is not fixed")
def test_input_has_save_rename_command(self, tree_view):
"""Test that editing input has SaveRename command.
Why these elements matter:
- Input element: Enables inline editing of node label
- TestCommand "SaveRename": Submits new label via HTMX on form submission
- name "node_label": Required for form data to include the new label value
- value with current label: Pre-fills input with existing node text
"""
node = TreeNode(label="Edit Me", type="file")
tree_view.add_node(node)
tree_view._start_rename(node.id)
# Step 1: Extract the input element
rendered = tree_view.render()
input_elem = find_one(rendered, Input(name="node_label"))
# Step 2: Define expected structure
expected = Input(
name="node_label",
value="Edit Me",
command=TestCommand(tree_view.commands.save_rename(node.id)),
cls=Contains("mf-treenode-input")
)
# Step 3: Compare
assert matches(input_elem, expected)
def test_keyboard_has_cancel_rename_command(self, tree_view):
"""Test that Keyboard component has Escape key bound to CancelRename.
Why these elements matter:
- TestObject Keyboard: Verifies keyboard shortcuts component is present
- esc combination with CancelRename: Enables canceling rename with Escape key
- Essential for UX: Users expect Escape to cancel inline editing
"""
# Step 1: Extract the Keyboard component
rendered = tree_view.render()
keyboard = find_one(rendered, TestObject(Keyboard))
# Step 2: Define expected structure
expected = TestObject(Keyboard, combinations={"esc": TestCommand("CancelRename")})
# Step 3: Compare
assert matches(keyboard, expected)
def test_multiple_root_nodes_are_rendered(self, tree_view):
"""Test that multiple root nodes are rendered at the same level.
Why these elements matter:
- Multiple root nodes: Verifies TreeView supports forest structure (multiple trees)
- All at same level: No artificial parent wrapping root nodes
- Each root has its own container: Proper structure for multiple independent trees
"""
root1 = TreeNode(label="Root 1", type="folder")
root2 = TreeNode(label="Root 2", type="folder")
tree_view.add_node(root1)
tree_view.add_node(root2)
rendered = tree_view.render()
root_containers = find(rendered, Div(cls=Contains("mf-treenode-container")))
assert len(root_containers) == 2, "Should have two root-level containers"
root1_container = find_one(rendered, Div(data_node_id=root1.id))
root2_container = find_one(rendered, Div(data_node_id=root2.id))
expected_root1 = Div(
Div(
Div(None), # No icon, leaf node
Span("Root 1"),
Div(), # Action buttons
cls=Contains("mf-treenode")
),
cls="mf-treenode-container",
data_node_id=root1.id
)
expected_root2 = Div(
Div(
Div(None), # No icon, leaf node
Span("Root 2"),
Div(), # Action buttons
cls=Contains("mf-treenode")
),
cls="mf-treenode-container",
data_node_id=root2.id
)
assert matches(root1_container, expected_root1)
assert matches(root2_container, expected_root2)

View File

@@ -91,7 +91,6 @@
for (const [combinationStr, config] of Object.entries(combinations)) {
const sequence = parseCombination(combinationStr);
console.log("Parsing combination", combinationStr, "=>", sequence);
let currentNode = root;
for (const keySet of sequence) {

View File

@@ -1,7 +1,22 @@
import pytest
from fasthtml.components import Div, Span
from fastcore.basics import NotStr
from fasthtml.components import Div, Span, Main
from myfasthtml.test.matcher import find
from myfasthtml.test.matcher import find, TestObject, Contains, StartsWith
class Dummy:
def __init__(self, attr1, attr2=None):
self.attr1 = attr1
self.attr2 = attr2
def __eq__(self, other):
return (isinstance(other, Dummy)
and self.attr1 == other.attr1
and self.attr2 == other.attr2)
def __hash__(self):
return hash((self.attr1, self.attr2))
@pytest.mark.parametrize('ft, expected', [
@@ -9,10 +24,13 @@ from myfasthtml.test.matcher import find
(Div(id="id1"), Div(id="id1")),
(Div(Span(id="span_id"), id="div_id1"), Div(Span(id="span_id"), id="div_id1")),
(Div(id="id1", id2="id2"), Div(id="id1")),
(Div(Div(id="id2"), id2="id1"), Div(id="id1")),
(Div(Div(id="id2"), id="id1"), Div(id="id1")),
(Dummy(attr1="value"), Dummy(attr1="value")),
(Dummy(attr1="value"), TestObject(Dummy, attr1="value")),
(Div(attr="value1 value2"), Div(attr=Contains("value1"))),
])
def test_i_can_find(ft, expected):
assert find(expected, expected) == [expected]
assert find(ft, expected) == [ft]
def test_find_element_by_id_in_a_list():
@@ -25,12 +43,41 @@ def test_find_element_by_id_in_a_list():
def test_i_can_find_sub_element():
a = Div(id="id1")
b = Div(a, id="id2")
c = Div(b, id="id3")
b = Span(a, id="id2")
c = Main(b, id="id3")
assert find(c, a) == [a]
def test_i_can_find_when_pattern_appears_also_in_children():
a1 = Div(id="id1")
b = Div(a1, id="id2")
a2 = Div(b, id="id1")
c = Main(a2, id="id3")
assert find(c, a1) == [a2, a1]
@pytest.mark.parametrize('ft, to_search, expected', [
(NotStr("hello"), NotStr("hello"), [NotStr("hello")]),
(NotStr("hello my friend"), NotStr("hello"), NotStr("hello my friend")),
(NotStr("hello"), TestObject(NotStr, s="hello"), [NotStr("hello")]),
(NotStr("hello my friend"), TestObject(NotStr, s=StartsWith("hello")), NotStr("hello my friend")),
])
def test_i_can_manage_notstr_success_path(ft, to_search, expected):
assert find(ft, to_search) == expected
@pytest.mark.parametrize('ft, to_search', [
(NotStr("my friend"), NotStr("hello")),
(NotStr("hello"), Dummy(attr1="hello")), # important, because of the internal __eq__ of NotStr
(NotStr("hello my friend"), TestObject(NotStr, s="hello")),
])
def test_test_i_can_manage_notstr_failure_path(ft, to_search):
with pytest.raises(AssertionError):
find(ft, to_search)
@pytest.mark.parametrize('ft, expected', [
(None, Div(id="id1")),
(Span(id="id1"), Div(id="id1")),

View File

@@ -2,8 +2,11 @@ import pytest
from fastcore.basics import NotStr
from fasthtml.components import *
from myfasthtml.test.matcher import matches, StartsWith, Contains, DoesNotContain, Empty, DoNotCheck, ErrorOutput, \
ErrorComparisonOutput, AttributeForbidden, AnyValue, NoChildren, TestObject
from myfasthtml.controls.helpers import mk
from myfasthtml.core.commands import Command
from myfasthtml.icons.fluent_p3 import add20_regular
from myfasthtml.test.matcher import matches, StartsWith, Contains, DoesNotContain, Empty, ErrorOutput, \
ErrorComparisonOutput, AttributeForbidden, AnyValue, NoChildren, TestObject, Skip, DoNotCheck, TestIcon, HasHtmx
from myfasthtml.test.testclient import MyFT
@@ -50,6 +53,12 @@ class TestMatches:
(Dummy(123, "value"), TestObject(Dummy, attr2="value")),
(Div(Dummy(123, "value")), Div(TestObject(Dummy, attr1=123))),
(Dummy(123, "value"), TestObject("Dummy", attr1=123, attr2="value")),
(mk.icon(add20_regular), TestIcon("Add20Regular")),
(mk.icon(add20_regular), TestIcon("add20_regular")),
(mk.icon(add20_regular), TestIcon()),
(Div(None, None, None, Div(id="to_find")), Div(Skip(None), Div(id="to_find"))),
(Div(Div(id="to_skip"), Div(id="to_skip"), Div(id="to_find")), Div(Skip(Div(id="to_skip")), Div(id="to_find"))),
(Div(hx_post="/url"), Div(HasHtmx(hx_post="/url"))),
])
def test_i_can_match(self, actual, expected):
assert matches(actual, expected)
@@ -63,8 +72,8 @@ class TestMatches:
([], [Div(), Span()], "Actual is smaller than expected"),
("not a list", [Div(), Span()], "The types are different"),
([Div(), Span()], [Div(), 123], "The types are different"),
(Div(), Span(), "The elements are different"),
([Div(), Span()], [Div(), Div()], "The elements are different"),
(Div(), Span(), "The types are different"),
([Div(), Span()], [Div(), Div()], "The types are different"),
(Div(), Div(attr1="value"), "'attr1' is not found in Actual"),
(Div(attr2="value"), Div(attr1="value"), "'attr1' is not found in Actual"),
(Div(attr1="value1"), Div(attr1="value2"), "The values are different for 'attr1'"),
@@ -80,21 +89,23 @@ class TestMatches:
(Div(Span()), Div(Empty()), "The condition 'Empty()' is not satisfied"),
(Div(), Div(Span()), "Actual is lesser than expected"),
(Div(), Div(123), "Actual is lesser than expected"),
(Div(Span()), Div(Div()), "The elements are different"),
(Div(Span()), Div(Div()), "The types are different"),
(Div(123), Div(Div()), "The types are different"),
(Div(123), Div(456), "The values are different"),
(Div(Span(), Span()), Div(Span(), Div()), "The elements are different"),
(Div(Span(Div())), Div(Span(Span())), "The elements are different"),
(Div(Span(), Span()), Div(Span(), Div()), "The types are different"),
(Div(Span(Div())), Div(Span(Span())), "The types are different"),
(Div(attr1="value1"), Div(AttributeForbidden("attr1")), "condition 'AttributeForbidden(attr1)' is not satisfied"),
(Div(123, "value"), TestObject(Dummy, attr1=123, attr2="value2"), "The types are different:"),
(Div(123, "value"), TestObject(Dummy, attr1=123, attr2="value2"), "The types are different"),
(Dummy(123, "value"), TestObject(Dummy, attr1=123, attr3="value3"), "'attr3' is not found in Actual"),
(Dummy(123, "value"), TestObject(Dummy, attr1=123, attr2="value2"), "The values are different for 'attr2'"),
(Div(Div(123, "value")), Div(TestObject(Dummy, attr1=123, attr2="value2")), "The types are different:"),
(Div(Div(123, "value")), Div(TestObject(Dummy, attr1=123, attr2="value2")), "The types are different"),
(Div(Dummy(123, "value")), Div(TestObject(Dummy, attr1=123, attr3="value3")), "'attr3' is not found in Actual"),
(Div(Dummy(123, "value")), Div(TestObject(Dummy, attr1=123, attr2="value2")), "are different for 'attr2'"),
(Div(123, "value"), TestObject("Dummy", attr1=123, attr2="value2"), "The types are different:"),
(Dummy(123, "value"), TestObject("Dummy", attr1=123, attr2=Contains("value2")), "The condition 'Contains(value2)' is not satisfied"),
(Div(123, "value"), TestObject("Dummy", attr1=123, attr2="value2"), "The types are different"),
(Dummy(123, "value"), TestObject("Dummy", attr1=123, attr2=Contains("value2")),
"The condition 'Contains(value2)' is not satisfied"),
(Div(Div(id="to_skip")), Div(Skip(Div(id="to_skip"))), "Nothing more to skip"),
(Div(hx_post="/url"), Div(HasHtmx(hx_post="/url2")), "The condition 'HasHtmx()' is not satisfied"),
])
def test_i_can_detect_errors(self, actual, expected, error_message):
with pytest.raises(AssertionError) as exc_info:
@@ -440,3 +451,20 @@ Error : The condition 'Contains(value2)' is not satisfied.
assert "\n" + res == '''
(div "attr1"="123" "attr2"="value2") | (Dummy "attr1"="123" "attr2"="value2")
^^^ |'''
class TestPredicates:
def test_i_can_validate_contains_with_words_only(self):
assert Contains("value", _word=True).validate("value value2 value3")
assert Contains("value", "value2", _word=True).validate("value value2 value3")
assert not Contains("value", _word=True).validate("valuevalue2value3")
assert not Contains("value value2", _word=True).validate("value value2 value3")
def test_i_can_validate_has_htmx(self):
div = Div(hx_post="/url")
assert HasHtmx(hx_post="/url").validate(div)
c = Command("c", "testing has_htmx", None)
c.bind_ft(div)
assert HasHtmx(command=c).validate(div)