"""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)