1038 lines
37 KiB
Python
1038 lines
37 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")
|
|
|
|
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"
|
|
|
|
def test_i_can_ensure_simple_path(self, root_instance):
|
|
"""Test that ensure_path creates a simple hierarchical path."""
|
|
tree_view = TreeView(root_instance)
|
|
|
|
tree_view.ensure_path("folder1.folder2.file")
|
|
|
|
# Should have 3 nodes
|
|
assert len(tree_view._state.items) == 3
|
|
|
|
# Find nodes by label
|
|
nodes = list(tree_view._state.items.values())
|
|
folder1 = [n for n in nodes if n.label == "folder1"][0]
|
|
folder2 = [n for n in nodes if n.label == "folder2"][0]
|
|
file_node = [n for n in nodes if n.label == "file"][0]
|
|
|
|
# Verify hierarchy
|
|
assert folder1.parent is None
|
|
assert folder2.parent == folder1.id
|
|
assert file_node.parent == folder2.id
|
|
|
|
# Verify children relationships
|
|
assert folder2.id in folder1.children
|
|
assert file_node.id in folder2.children
|
|
|
|
# Verify all nodes have folder type
|
|
assert folder1.type == "folder"
|
|
assert folder2.type == "folder"
|
|
assert file_node.type == "folder"
|
|
|
|
def test_i_can_ensure_path_with_existing_nodes(self, root_instance):
|
|
"""Test that ensure_path reuses existing nodes in the path."""
|
|
tree_view = TreeView(root_instance)
|
|
|
|
# Create initial path
|
|
tree_view.ensure_path("folder1.folder2.file1")
|
|
initial_count = len(tree_view._state.items)
|
|
|
|
# Ensure same path again
|
|
tree_view.ensure_path("folder1.folder2.file1")
|
|
|
|
# Should not create any new nodes
|
|
assert len(tree_view._state.items) == initial_count
|
|
assert len(tree_view._state.items) == 3
|
|
|
|
def test_i_can_ensure_path_partially_existing(self, root_instance):
|
|
"""Test that ensure_path creates only missing nodes when path partially exists."""
|
|
tree_view = TreeView(root_instance)
|
|
|
|
# Create initial path
|
|
tree_view.ensure_path("folder1.folder2")
|
|
assert len(tree_view._state.items) == 2
|
|
|
|
# Extend the path
|
|
tree_view.ensure_path("folder1.folder2.subfolder.file")
|
|
|
|
# Should have 4 nodes total (folder1, folder2, subfolder, file)
|
|
assert len(tree_view._state.items) == 4
|
|
|
|
# Find all nodes
|
|
nodes = list(tree_view._state.items.values())
|
|
folder1 = [n for n in nodes if n.label == "folder1"][0]
|
|
folder2 = [n for n in nodes if n.label == "folder2"][0]
|
|
subfolder = [n for n in nodes if n.label == "subfolder"][0]
|
|
file_node = [n for n in nodes if n.label == "file"][0]
|
|
|
|
# Verify new nodes are children of existing path
|
|
assert subfolder.parent == folder2.id
|
|
assert file_node.parent == subfolder.id
|
|
|
|
def test_i_cannot_ensure_path_with_none(self, root_instance):
|
|
"""Test that ensure_path raises ValueError when path is None."""
|
|
tree_view = TreeView(root_instance)
|
|
|
|
with pytest.raises(ValueError, match="Invalid path.*None"):
|
|
tree_view.ensure_path(None)
|
|
|
|
def test_i_cannot_ensure_path_with_empty_string(self, root_instance):
|
|
"""Test that ensure_path raises ValueError for empty strings after stripping."""
|
|
tree_view = TreeView(root_instance)
|
|
|
|
with pytest.raises(ValueError, match="Invalid path.*empty"):
|
|
tree_view.ensure_path(" ")
|
|
|
|
with pytest.raises(ValueError, match="Invalid path.*empty"):
|
|
tree_view.ensure_path("")
|
|
|
|
with pytest.raises(ValueError, match="Invalid path.*empty"):
|
|
tree_view.ensure_path("...")
|
|
|
|
def test_i_can_ensure_path_strips_leading_trailing_dots(self, root_instance):
|
|
"""Test that ensure_path strips leading and trailing dots."""
|
|
tree_view = TreeView(root_instance)
|
|
|
|
tree_view.ensure_path(".folder1.folder2.")
|
|
|
|
# Should only create 2 nodes (folder1, folder2)
|
|
assert len(tree_view._state.items) == 2
|
|
|
|
nodes = list(tree_view._state.items.values())
|
|
labels = [n.label for n in nodes]
|
|
|
|
assert "folder1" in labels
|
|
assert "folder2" in labels
|
|
|
|
def test_i_can_ensure_path_strips_spaces_in_parts(self, root_instance):
|
|
"""Test that ensure_path strips spaces from each path part."""
|
|
tree_view = TreeView(root_instance)
|
|
|
|
tree_view.ensure_path("folder1. folder2 . folder3 ")
|
|
|
|
# Should create 3 nodes with trimmed labels
|
|
assert len(tree_view._state.items) == 3
|
|
|
|
nodes = list(tree_view._state.items.values())
|
|
labels = [n.label for n in nodes]
|
|
|
|
assert "folder1" in labels
|
|
assert "folder2" in labels
|
|
assert "folder3" in labels
|
|
|
|
def test_ensure_path_creates_folder_type_nodes(self, root_instance):
|
|
"""Test that ensure_path creates nodes with type='folder'."""
|
|
tree_view = TreeView(root_instance)
|
|
|
|
tree_view.ensure_path("folder1.folder2")
|
|
|
|
for node in tree_view._state.items.values():
|
|
assert node.type == "folder"
|
|
|
|
def test_i_cannot_ensure_path_with_empty_parts(self, root_instance):
|
|
"""Test that ensure_path raises ValueError for paths with empty parts."""
|
|
tree_view = TreeView(root_instance)
|
|
|
|
with pytest.raises(ValueError, match="Invalid path"):
|
|
tree_view.ensure_path("folder1..folder2")
|
|
|
|
def test_i_cannot_ensure_path_with_only_spaces_parts(self, root_instance):
|
|
"""Test that ensure_path raises ValueError for path parts with only spaces."""
|
|
tree_view = TreeView(root_instance)
|
|
|
|
with pytest.raises(ValueError, match="Invalid path"):
|
|
tree_view.ensure_path("folder1. .folder2")
|
|
|
|
def test_ensure_path_returns_last_node_id(self, root_instance):
|
|
"""Test that ensure_path returns the ID of the last node in the path."""
|
|
tree_view = TreeView(root_instance)
|
|
|
|
# Create a path and get the returned ID
|
|
returned_id = tree_view.ensure_path("folder1.folder2.folder3")
|
|
|
|
# Verify the returned ID is not None
|
|
assert returned_id is not None
|
|
|
|
# Verify the returned ID corresponds to folder3
|
|
assert returned_id in tree_view._state.items
|
|
assert tree_view._state.items[returned_id].label == "folder3"
|
|
|
|
# Verify we can use this ID to add a child
|
|
leaf = TreeNode(label="file.txt", type="file")
|
|
tree_view.add_node(leaf, parent_id=returned_id)
|
|
|
|
assert leaf.parent == returned_id
|
|
assert leaf.id in tree_view._state.items[returned_id].children
|
|
|
|
def test_ensure_path_returns_existing_node_id(self, root_instance):
|
|
"""Test that ensure_path returns ID even when path already exists."""
|
|
tree_view = TreeView(root_instance)
|
|
|
|
# Create initial path
|
|
first_id = tree_view.ensure_path("folder1.folder2")
|
|
|
|
# Ensure same path again
|
|
second_id = tree_view.ensure_path("folder1.folder2")
|
|
|
|
# Should return the same ID
|
|
assert first_id == second_id
|
|
assert tree_view._state.items[first_id].label == "folder2"
|
|
|
|
|
|
class TestTreeViewRender:
|
|
"""Tests for TreeView HTML rendering."""
|
|
|
|
@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(),
|
|
cls="mf-treeview"
|
|
)
|
|
|
|
assert matches(tree_view.__ft__(), expected)
|
|
|
|
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)
|