Added Controls testing + documentation
This commit is contained in:
@@ -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)
|
||||
|
||||
677
tests/controls/test_layout.py
Normal file
677
tests/controls/test_layout.py
Normal 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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")),
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user