Files
MyFastHtml/tests/controls/test_treeview.py

628 lines
22 KiB
Python

"""Unit tests for TreeView component."""
import shutil
import pytest
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, TestIcon, find_one, find, Contains, DoesNotContain
from .conftest import root_instance
@pytest.fixture(autouse=True)
def cleanup_db():
shutil.rmtree(".myFastHtmlDb", ignore_errors=True)
class TestTreeviewBehaviour:
"""Tests for TreeView behavior and logic."""
def test_i_can_create_tree_node_with_auto_generated_id(self):
"""Test that TreeNode generates UUID automatically."""
node = TreeNode(label="Test Node", type="folder")
assert node.id is not None
assert isinstance(node.id, str)
assert len(node.id) > 0
def test_i_can_create_tree_node_with_default_values(self):
"""Test that TreeNode has correct default values."""
node = TreeNode()
assert node.label == ""
assert node.type == "default"
assert node.parent is None
assert node.children == []
def test_i_can_initialize_tree_view_state(self, root_instance):
"""Test that TreeViewState initializes with default values."""
tree_view = TreeView(root_instance)
state = tree_view._state
assert isinstance(state.items, dict)
assert len(state.items) == 0
assert state.opened == []
assert state.selected is None
assert state.editing is None
assert state.icon_config == {}
def test_i_can_create_empty_treeview(self, root_instance):
"""Test creating an empty TreeView."""
tree_view = TreeView(root_instance)
assert tree_view is not None
assert len(tree_view._state.items) == 0
def test_i_can_add_node_to_treeview(self, root_instance):
"""Test adding a root node to the TreeView."""
tree_view = TreeView(root_instance)
node = TreeNode(label="Root Node", type="folder")
tree_view.add_node(node)
assert node.id in tree_view._state.items
assert tree_view._state.items[node.id].label == "Root Node"
assert tree_view._state.items[node.id].parent is None
def test_i_can_add_child_node(self, root_instance):
"""Test adding a child node to a parent."""
tree_view = TreeView(root_instance)
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)
assert child.id in tree_view._state.items
assert child.id in parent.children
assert child.parent == parent.id
def test_i_can_set_icon_config(self, root_instance):
"""Test setting icon configuration."""
tree_view = TreeView(root_instance)
config = {
"folder": "fluent.folder",
"file": "fluent.document"
}
tree_view.set_icon_config(config)
assert tree_view._state.icon_config == config
assert tree_view._state.icon_config["folder"] == "fluent.folder"
def test_i_can_toggle_node(self, root_instance):
"""Test expand/collapse a node."""
tree_view = TreeView(root_instance)
node = TreeNode(label="Node", type="folder")
tree_view.add_node(node)
# Initially closed
assert node.id not in tree_view._state.opened
# Toggle to open
tree_view._toggle_node(node.id)
assert node.id in tree_view._state.opened
# Toggle to close
tree_view._toggle_node(node.id)
assert node.id not in tree_view._state.opened
def test_i_can_expand_all_nodes(self, root_instance):
"""Test that expand_all opens all nodes with children."""
tree_view = TreeView(root_instance)
# Create hierarchy: root -> child1 -> grandchild
# -> child2 (leaf)
root = TreeNode(label="Root", type="folder")
child1 = TreeNode(label="Child 1", type="folder")
grandchild = TreeNode(label="Grandchild", type="file")
child2 = TreeNode(label="Child 2", type="file")
tree_view.add_node(root)
tree_view.add_node(child1, parent_id=root.id)
tree_view.add_node(grandchild, parent_id=child1.id)
tree_view.add_node(child2, parent_id=root.id)
# Initially all closed
assert len(tree_view._state.opened) == 0
# Expand all
tree_view.expand_all()
# Nodes with children should be opened
assert root.id in tree_view._state.opened
assert child1.id in tree_view._state.opened
# Leaf nodes should not be in opened list
assert grandchild.id not in tree_view._state.opened
assert child2.id not in tree_view._state.opened
def test_i_can_select_node(self, root_instance):
"""Test selecting a node."""
tree_view = TreeView(root_instance)
node = TreeNode(label="Node", type="folder")
tree_view.add_node(node)
tree_view._select_node(node.id)
assert tree_view._state.selected == node.id
def test_i_can_start_rename_node(self, root_instance):
"""Test starting rename mode for a node."""
tree_view = TreeView(root_instance)
node = TreeNode(label="Old Name", type="folder")
tree_view.add_node(node)
tree_view._start_rename(node.id)
assert tree_view._state.editing == node.id
def test_i_can_save_rename_node(self, root_instance):
"""Test saving renamed node with new label."""
tree_view = TreeView(root_instance)
node = TreeNode(label="Old Name", type="folder")
tree_view.add_node(node)
tree_view._start_rename(node.id)
tree_view._save_rename(node.id, "New Name")
assert tree_view._state.items[node.id].label == "New Name"
assert tree_view._state.editing is None
def test_i_can_cancel_rename_node(self, root_instance):
"""Test canceling rename operation."""
tree_view = TreeView(root_instance)
node = TreeNode(label="Name", type="folder")
tree_view.add_node(node)
tree_view._start_rename(node.id)
tree_view._cancel_rename()
assert tree_view._state.editing is None
assert tree_view._state.items[node.id].label == "Name"
def test_i_can_delete_leaf_node(self, root_instance):
"""Test deleting a node without children."""
tree_view = TreeView(root_instance)
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)
# Delete child (leaf node)
tree_view._delete_node(child.id)
assert child.id not in tree_view._state.items
assert child.id not in parent.children
def test_i_can_add_sibling_node(self, root_instance):
"""Test adding a sibling node."""
tree_view = TreeView(root_instance)
parent = TreeNode(label="Parent", type="folder")
child1 = TreeNode(label="Child 1", type="file")
tree_view.add_node(parent)
tree_view.add_node(child1, parent_id=parent.id)
# Add sibling to child1
tree_view._add_sibling(child1.id, new_label="Child 2")
assert len(parent.children) == 2
# Sibling should be after child1
assert parent.children.index(child1.id) < len(parent.children) - 1
def test_i_cannot_delete_node_with_children(self, root_instance):
"""Test that deleting a node with children raises an error."""
tree_view = TreeView(root_instance)
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)
# Try to delete parent (has children)
with pytest.raises(ValueError, match="Cannot delete node.*with children"):
tree_view._delete_node(parent.id)
def test_i_cannot_add_sibling_to_root(self, root_instance):
"""Test that adding sibling to root node raises an error."""
tree_view = TreeView(root_instance)
root = TreeNode(label="Root", type="folder")
tree_view.add_node(root)
# Try to add sibling to root (no parent)
with pytest.raises(ValueError, match="Cannot add sibling to root node"):
tree_view._add_sibling(root.id)
def test_i_cannot_select_nonexistent_node(self, root_instance):
"""Test that selecting a nonexistent node raises an error."""
tree_view = TreeView(root_instance)
# Try to select node that doesn't exist
with pytest.raises(ValueError, match="Node.*does not exist"):
tree_view._select_node("nonexistent_id")
def test_add_node_prevents_duplicate_children(self, root_instance):
"""Test that add_node prevents adding duplicate child IDs."""
tree_view = TreeView(root_instance)
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)
# Try to add the same child again
tree_view.add_node(child, parent_id=parent.id)
# Child should appear only once in parent's children list
assert parent.children.count(child.id) == 1
def test_sibling_is_inserted_at_correct_position(self, root_instance):
"""Test that _add_sibling inserts sibling exactly after reference node."""
tree_view = TreeView(root_instance)
parent = TreeNode(label="Parent", type="folder")
child1 = TreeNode(label="Child 1", type="file")
child3 = TreeNode(label="Child 3", type="file")
tree_view.add_node(parent)
tree_view.add_node(child1, parent_id=parent.id)
tree_view.add_node(child3, parent_id=parent.id)
# Add sibling after child1
tree_view._add_sibling(child1.id, new_label="Child 2")
# Get the newly added sibling
sibling_id = parent.children[1]
# Verify order: child1, sibling (child2), child3
assert parent.children[0] == child1.id
assert tree_view._state.items[sibling_id].label == "Child 2"
assert parent.children[2] == child3.id
assert len(parent.children) == 3
def test_add_child_auto_expands_parent(self, root_instance):
"""Test that _add_child automatically expands the parent node."""
tree_view = TreeView(root_instance)
parent = TreeNode(label="Parent", type="folder")
tree_view.add_node(parent)
# Parent should not be expanded initially
assert parent.id not in tree_view._state.opened
# Add child
tree_view._add_child(parent.id, new_label="Child")
# Parent should now be expanded
assert parent.id in tree_view._state.opened
def test_i_cannot_add_child_to_nonexistent_parent(self, root_instance):
"""Test that adding child to nonexistent parent raises error."""
tree_view = TreeView(root_instance)
# Try to add child to parent that doesn't exist
with pytest.raises(ValueError, match="Parent node.*does not exist"):
tree_view._add_child("nonexistent_parent_id")
def test_delete_node_clears_selection_if_selected(self, root_instance):
"""Test that deleting a selected node clears the selection."""
tree_view = TreeView(root_instance)
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)
# Select the child
tree_view._select_node(child.id)
assert tree_view._state.selected == child.id
# Delete the selected child
tree_view._delete_node(child.id)
# Selection should be cleared
assert tree_view._state.selected is None
def test_delete_node_removes_from_opened_if_expanded(self, root_instance):
"""Test that deleting an expanded node removes it from opened list."""
tree_view = TreeView(root_instance)
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)
# Expand the parent
tree_view._toggle_node(parent.id)
assert parent.id in tree_view._state.opened
# Delete the child (making parent a leaf)
tree_view._delete_node(child.id)
# Now delete the parent (now a leaf node)
# First remove it from root by creating a grandparent
grandparent = TreeNode(label="Grandparent", type="folder")
tree_view.add_node(grandparent)
parent.parent = grandparent.id
grandparent.children.append(parent.id)
tree_view._delete_node(parent.id)
# Parent should be removed from opened list
assert parent.id not in tree_view._state.opened
def test_i_cannot_start_rename_nonexistent_node(self, root_instance):
"""Test that starting rename on nonexistent node raises error."""
tree_view = TreeView(root_instance)
# Try to start rename on node that doesn't exist
with pytest.raises(ValueError, match="Node.*does not exist"):
tree_view._start_rename("nonexistent_id")
def test_i_cannot_save_rename_nonexistent_node(self, root_instance):
"""Test that saving rename for nonexistent node raises error."""
tree_view = TreeView(root_instance)
# Try to save rename for node that doesn't exist
with pytest.raises(ValueError, match="Node.*does not exist"):
tree_view._save_rename("nonexistent_id", "New Name")
def test_i_cannot_add_sibling_to_nonexistent_node(self, root_instance):
"""Test that adding sibling to nonexistent node raises error."""
tree_view = TreeView(root_instance)
# 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")
class TestTreeViewRender:
"""Tests for TreeView HTML rendering."""
def test_empty_treeview_is_rendered(self, root_instance):
"""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
"""
# Step 1: Create empty TreeView
tree_view = TreeView(root_instance)
# Step 2: Define expected structure
expected = Div(
TestObject(Keyboard, combinations={"esc": TestCommand("CancelRename")}),
_id=tree_view.get_id(),
cls="mf-treeview"
)
# Step 3: Compare
assert matches(tree_view.__ft__(), expected)
@pytest.fixture
def tree_view(self, root_instance):
return TreeView(root_instance)
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()
node_container = find_one(rendered, Div(data_node_id=parent.id))
# Step 2: Define expected structure
expected = 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"),
data_node_id=parent.id
)
# Step 3: Compare
assert matches(node_container, expected)
# Verify no children are rendered (collapsed)
child_containers = find(node_container, Div(data_node_id=child.id))
assert len(child_containers) == 0, "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
"""
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)
tree_view._toggle_node(parent.id) # Expand the parent
# Step 1: Extract the parent node element to test
rendered = tree_view.render()
parent_node = find_one(rendered, Div(data_node_id=parent.id))
# Step 2: Define expected structure for toggle icon
expected = Div(
TestIcon("chevron_down20_regular"), # Expanded toggle icon
cls=Contains("mf-treenode")
)
# Step 3: Compare
assert matches(parent_node, expected)
# Verify children ARE rendered (expanded)
child_containers = find(rendered, Div(data_node_id=child.id))
assert len(child_containers) == 1, "Child should be rendered when parent is expanded"
# Verify child has proper node structure
child_node = child_containers[0]
child_expected = Div(
Span("Child"),
cls=Contains("mf-treenode"),
data_node_id=child.id
)
assert matches(child_node, child_expected)
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_node = find_one(rendered, Div(data_node_id=leaf.id))
# Step 2: Define expected structure
expected = Div(
Span("Leaf Node"), # Label
Div( # Action buttons still present
TestIcon("add_circle20_regular"),
TestIcon("edit20_regular"),
TestIcon("delete20_regular"),
cls=Contains("mf-treenode-actions")
),
cls=Contains("mf-treenode"),
data_node_id=leaf.id
)
# Step 3: Compare
assert matches(leaf_node, 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
- 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)
# Step 1: Extract the selected node element to test
rendered = tree_view.render()
selected_node = find_one(rendered, Div(data_node_id=node.id))
# Step 2: Define expected structure
expected = Div(
cls=Contains("mf-treenode", "selected"),
data_node_id=node.id
)
# Step 3: Compare
assert matches(selected_node, 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)
# Step 1: Extract the editing node element to test
rendered = tree_view.render()
editing_node = find_one(rendered, Div(data_node_id=node.id))
# Step 2: Define expected structure
expected = Div(
Input(
name="node_label",
value="Edit Me",
cls=Contains("mf-treenode-input")
),
cls=Contains("mf-treenode"),
data_node_id=node.id
)
# Step 3: Compare
assert matches(editing_node, expected)
# Verify "selected" class is NOT present
no_selected = Div(
cls=DoesNotContain("selected")
)
assert matches(editing_node, 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
"""
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()
# Step 1 & 2 & 3: Test root node (level 0)
root_node = find_one(rendered, Div(data_node_id=root.id))
root_expected = Div(
style=Contains("padding-left: 0px")
)
assert matches(root_node, root_expected)
# Step 1 & 2 & 3: Test child node (level 1)
child_node = find_one(rendered, Div(data_node_id=child.id))
child_expected = Div(
style=Contains("padding-left: 20px")
)
assert matches(child_node, child_expected)
# Step 1 & 2 & 3: Test grandchild node (level 2)
grandchild_node = find_one(rendered, Div(data_node_id=grandchild.id))
grandchild_expected = Div(
style=Contains("padding-left: 40px")
)
assert matches(grandchild_node, grandchild_expected)