Updated NetworkVis + unit tests
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import json
|
||||
import logging
|
||||
|
||||
from fasthtml.components import Script, Div
|
||||
@@ -51,18 +52,17 @@ class VisNetwork(MultipleInstance):
|
||||
self._state.update(state)
|
||||
|
||||
def render(self):
|
||||
# Prepare JS arrays (no JSON library needed)
|
||||
|
||||
# Serialize nodes and edges to JSON
|
||||
# This preserves all properties (color, shape, size, etc.) that are present
|
||||
js_nodes = ",\n ".join(
|
||||
f'{{ id: {n["id"]}, label: "{n.get("label", "")}" }}'
|
||||
for n in self._state.nodes
|
||||
json.dumps(node) for node in self._state.nodes
|
||||
)
|
||||
js_edges = ",\n ".join(
|
||||
f'{{ from: {e["from"]}, to: {e["to"]} }}'
|
||||
for e in self._state.edges
|
||||
json.dumps(edge) for edge in self._state.edges
|
||||
)
|
||||
|
||||
# Convert Python options to JS
|
||||
import json
|
||||
js_options = json.dumps(self._state.options, indent=2)
|
||||
|
||||
return (
|
||||
|
||||
238
src/myfasthtml/core/network_utils.py
Normal file
238
src/myfasthtml/core/network_utils.py
Normal file
@@ -0,0 +1,238 @@
|
||||
from collections.abc import Callable
|
||||
|
||||
|
||||
def from_nested_dict(trees: list[dict]) -> tuple[list, list]:
|
||||
"""
|
||||
Convert a list of nested dictionaries to vis.js nodes and edges format.
|
||||
|
||||
Args:
|
||||
trees: List of nested dictionaries where keys are node names and values are
|
||||
dictionaries of children (e.g., [{"root1": {"child1": {}}}, {"root2": {}}])
|
||||
|
||||
Returns:
|
||||
tuple: (nodes, edges) where:
|
||||
- nodes: list of dicts with auto-incremented numeric IDs
|
||||
- edges: list of dicts with 'from' and 'to' keys
|
||||
|
||||
Example:
|
||||
>>> trees = [{"root1": {"child1": {}}}, {"root2": {"child2": {}}}]
|
||||
>>> nodes, edges = from_nested_dict(trees)
|
||||
>>> # nodes = [{"id": 1, "label": "root1"}, {"id": 2, "label": "child1"}, ...]
|
||||
"""
|
||||
nodes = []
|
||||
edges = []
|
||||
node_id_counter = 1
|
||||
node_id_map = {} # maps node_label -> node_id
|
||||
|
||||
def traverse(subtree: dict, parent_id: int | None = None):
|
||||
nonlocal node_id_counter
|
||||
|
||||
for node_label, children in subtree.items():
|
||||
# Create node with auto-incremented ID
|
||||
current_id = node_id_counter
|
||||
node_id_map[node_label] = current_id
|
||||
nodes.append({
|
||||
"id": current_id,
|
||||
"label": node_label
|
||||
})
|
||||
node_id_counter += 1
|
||||
|
||||
# Create edge from parent to current node
|
||||
if parent_id is not None:
|
||||
edges.append({
|
||||
"from": parent_id,
|
||||
"to": current_id
|
||||
})
|
||||
|
||||
# Recursively process children
|
||||
if children:
|
||||
traverse(children, parent_id=current_id)
|
||||
|
||||
# Process each tree in the list
|
||||
for tree in trees:
|
||||
traverse(tree)
|
||||
|
||||
return nodes, edges
|
||||
|
||||
|
||||
def from_tree_with_metadata(
|
||||
trees: list[dict],
|
||||
id_getter: Callable = None,
|
||||
label_getter: Callable = None,
|
||||
children_getter: Callable = None
|
||||
) -> tuple[list, list]:
|
||||
"""
|
||||
Convert a list of trees with metadata to vis.js nodes and edges format.
|
||||
|
||||
Args:
|
||||
trees: List of dictionaries with 'id', 'label', and 'children' keys
|
||||
(e.g., [{"id": "root1", "label": "Root 1", "children": [...]}, ...])
|
||||
id_getter: Optional callback to extract node ID from dict
|
||||
Default: lambda n: n.get("id")
|
||||
label_getter: Optional callback to extract node label from dict
|
||||
Default: lambda n: n.get("label", "")
|
||||
children_getter: Optional callback to extract children list from dict
|
||||
Default: lambda n: n.get("children", [])
|
||||
|
||||
Returns:
|
||||
tuple: (nodes, edges) where:
|
||||
- nodes: list of dicts with IDs from tree or auto-incremented
|
||||
- edges: list of dicts with 'from' and 'to' keys
|
||||
|
||||
Example:
|
||||
>>> trees = [
|
||||
... {
|
||||
... "id": "root1",
|
||||
... "label": "Root Node 1",
|
||||
... "children": [
|
||||
... {"id": "child1", "label": "Child 1", "children": []}
|
||||
... ]
|
||||
... },
|
||||
... {"id": "root2", "label": "Root Node 2"}
|
||||
... ]
|
||||
>>> nodes, edges = from_tree_with_metadata(trees)
|
||||
"""
|
||||
# Default getters
|
||||
if id_getter is None:
|
||||
id_getter = lambda n: n.get("id")
|
||||
if label_getter is None:
|
||||
label_getter = lambda n: n.get("label", "")
|
||||
if children_getter is None:
|
||||
children_getter = lambda n: n.get("children", [])
|
||||
|
||||
nodes = []
|
||||
edges = []
|
||||
node_id_counter = 1
|
||||
|
||||
def traverse(node_dict: dict, parent_id: int | str | None = None):
|
||||
nonlocal node_id_counter
|
||||
|
||||
# Extract ID (use provided or auto-increment)
|
||||
node_id = id_getter(node_dict)
|
||||
if node_id is None:
|
||||
node_id = node_id_counter
|
||||
node_id_counter += 1
|
||||
|
||||
# Extract label
|
||||
node_label = label_getter(node_dict)
|
||||
|
||||
# Create node
|
||||
nodes.append({
|
||||
"id": node_id,
|
||||
"label": node_label
|
||||
})
|
||||
|
||||
# Create edge from parent to current node
|
||||
if parent_id is not None:
|
||||
edges.append({
|
||||
"from": parent_id,
|
||||
"to": node_id
|
||||
})
|
||||
|
||||
# Recursively process children
|
||||
children = children_getter(node_dict)
|
||||
if children:
|
||||
for child in children:
|
||||
traverse(child, parent_id=node_id)
|
||||
|
||||
# Process each tree in the list
|
||||
for tree in trees:
|
||||
traverse(tree)
|
||||
|
||||
return nodes, edges
|
||||
|
||||
|
||||
def from_parent_child_list(
|
||||
items: list,
|
||||
id_getter: callable = None,
|
||||
label_getter: callable = None,
|
||||
parent_getter: callable = None,
|
||||
ghost_color: str = "#ff9999"
|
||||
) -> tuple[list, list]:
|
||||
"""
|
||||
Convert a list of items with parent references to vis.js nodes and edges format.
|
||||
|
||||
Args:
|
||||
items: List of items (dicts or objects) with parent references
|
||||
(e.g., [{"id": "child", "parent": "root", "label": "Child"}, ...])
|
||||
id_getter: Optional callback to extract node ID from item
|
||||
Default: lambda item: item.get("id")
|
||||
label_getter: Optional callback to extract node label from item
|
||||
Default: lambda item: item.get("label", "")
|
||||
parent_getter: Optional callback to extract parent ID from item
|
||||
Default: lambda item: item.get("parent")
|
||||
ghost_color: Color to use for ghost nodes (nodes referenced as parents but not in list)
|
||||
Default: "#ff9999" (light red)
|
||||
|
||||
Returns:
|
||||
tuple: (nodes, edges) where:
|
||||
- nodes: list of dicts with IDs from items, ghost nodes have color property
|
||||
- edges: list of dicts with 'from' and 'to' keys
|
||||
|
||||
Note:
|
||||
- Nodes with parent=None or parent="" are treated as root nodes
|
||||
- If a parent is referenced but doesn't exist in items, a ghost node is created
|
||||
with the ghost_color applied
|
||||
|
||||
Example:
|
||||
>>> items = [
|
||||
... {"id": "root", "label": "Root"},
|
||||
... {"id": "child1", "parent": "root", "label": "Child 1"},
|
||||
... {"id": "child2", "parent": "unknown", "label": "Child 2"}
|
||||
... ]
|
||||
>>> nodes, edges = from_parent_child_list(items)
|
||||
>>> # "unknown" will be created as a ghost node with color="#ff9999"
|
||||
"""
|
||||
# Default getters
|
||||
if id_getter is None:
|
||||
id_getter = lambda item: item.get("id")
|
||||
if label_getter is None:
|
||||
label_getter = lambda item: item.get("label", "")
|
||||
if parent_getter is None:
|
||||
parent_getter = lambda item: item.get("parent")
|
||||
|
||||
nodes = []
|
||||
edges = []
|
||||
|
||||
# Track all existing node IDs
|
||||
existing_ids = set()
|
||||
|
||||
# First pass: create nodes for all items
|
||||
for item in items:
|
||||
node_id = id_getter(item)
|
||||
node_label = label_getter(item)
|
||||
|
||||
existing_ids.add(node_id)
|
||||
nodes.append({
|
||||
"id": node_id,
|
||||
"label": node_label
|
||||
})
|
||||
|
||||
# Track ghost nodes to avoid duplicates
|
||||
ghost_nodes = set()
|
||||
|
||||
# Second pass: create edges and identify ghost nodes
|
||||
for item in items:
|
||||
node_id = id_getter(item)
|
||||
parent_id = parent_getter(item)
|
||||
|
||||
# Skip if no parent or parent is empty string or None
|
||||
if parent_id is None or parent_id == "":
|
||||
continue
|
||||
|
||||
# Create edge from parent to child
|
||||
edges.append({
|
||||
"from": parent_id,
|
||||
"to": node_id
|
||||
})
|
||||
|
||||
# Check if parent exists, if not create ghost node
|
||||
if parent_id not in existing_ids and parent_id not in ghost_nodes:
|
||||
ghost_nodes.add(parent_id)
|
||||
nodes.append({
|
||||
"id": parent_id,
|
||||
"label": str(parent_id), # Use ID as label for ghost nodes
|
||||
"color": ghost_color
|
||||
})
|
||||
|
||||
return nodes, edges
|
||||
515
tests/core/test_network_utils.py
Normal file
515
tests/core/test_network_utils.py
Normal file
@@ -0,0 +1,515 @@
|
||||
from myfasthtml.core.network_utils import from_nested_dict, from_tree_with_metadata, from_parent_child_list
|
||||
|
||||
|
||||
class TestFromNestedDict:
|
||||
def test_i_can_convert_single_root_node(self):
|
||||
"""Test conversion of a single root node without children."""
|
||||
trees = [{"root": {}}]
|
||||
nodes, edges = from_nested_dict(trees)
|
||||
|
||||
assert len(nodes) == 1
|
||||
assert nodes[0] == {"id": 1, "label": "root"}
|
||||
assert len(edges) == 0
|
||||
|
||||
def test_i_can_convert_tree_with_one_level_children(self):
|
||||
"""Test conversion with direct children, verifying edge creation."""
|
||||
trees = [{"root": {"child1": {}, "child2": {}}}]
|
||||
nodes, edges = from_nested_dict(trees)
|
||||
|
||||
assert len(nodes) == 3
|
||||
assert nodes[0] == {"id": 1, "label": "root"}
|
||||
assert nodes[1] == {"id": 2, "label": "child1"}
|
||||
assert nodes[2] == {"id": 3, "label": "child2"}
|
||||
|
||||
assert len(edges) == 2
|
||||
assert {"from": 1, "to": 2} in edges
|
||||
assert {"from": 1, "to": 3} in edges
|
||||
|
||||
def test_i_can_convert_tree_with_multiple_levels(self):
|
||||
"""Test recursive conversion with multiple levels of nesting."""
|
||||
trees = [
|
||||
{
|
||||
"root": {
|
||||
"child1": {
|
||||
"grandchild1": {},
|
||||
"grandchild2": {}
|
||||
},
|
||||
"child2": {}
|
||||
}
|
||||
}
|
||||
]
|
||||
nodes, edges = from_nested_dict(trees)
|
||||
|
||||
assert len(nodes) == 5
|
||||
assert len(edges) == 4
|
||||
|
||||
# Verify hierarchy
|
||||
assert {"from": 1, "to": 2} in edges # root -> child1
|
||||
assert {"from": 1, "to": 5} in edges # root -> child2
|
||||
assert {"from": 2, "to": 3} in edges # child1 -> grandchild1
|
||||
assert {"from": 2, "to": 4} in edges # child1 -> grandchild2
|
||||
|
||||
def test_i_can_generate_auto_incremented_ids(self):
|
||||
"""Test that IDs start at 1 and increment correctly."""
|
||||
trees = [{"a": {"b": {"c": {}}}}]
|
||||
nodes, edges = from_nested_dict(trees)
|
||||
|
||||
ids = [node["id"] for node in nodes]
|
||||
assert ids == [1, 2, 3]
|
||||
|
||||
def test_i_can_use_dict_keys_as_labels(self):
|
||||
"""Test that dictionary keys become node labels."""
|
||||
trees = [{"RootNode": {"ChildNode": {}}}]
|
||||
nodes, edges = from_nested_dict(trees)
|
||||
|
||||
assert nodes[0]["label"] == "RootNode"
|
||||
assert nodes[1]["label"] == "ChildNode"
|
||||
|
||||
def test_i_can_convert_empty_list(self):
|
||||
"""Test that empty list returns empty nodes and edges."""
|
||||
trees = []
|
||||
nodes, edges = from_nested_dict(trees)
|
||||
|
||||
assert nodes == []
|
||||
assert edges == []
|
||||
|
||||
def test_i_can_convert_multiple_root_nodes(self):
|
||||
"""Test conversion with multiple independent trees."""
|
||||
trees = [
|
||||
{"root1": {"child1": {}}},
|
||||
{"root2": {"child2": {}}}
|
||||
]
|
||||
nodes, edges = from_nested_dict(trees)
|
||||
|
||||
assert len(nodes) == 4
|
||||
assert nodes[0] == {"id": 1, "label": "root1"}
|
||||
assert nodes[1] == {"id": 2, "label": "child1"}
|
||||
assert nodes[2] == {"id": 3, "label": "root2"}
|
||||
assert nodes[3] == {"id": 4, "label": "child2"}
|
||||
|
||||
# Verify edges connect within trees, not across
|
||||
assert len(edges) == 2
|
||||
assert {"from": 1, "to": 2} in edges
|
||||
assert {"from": 3, "to": 4} in edges
|
||||
|
||||
def test_i_can_maintain_id_sequence_across_multiple_trees(self):
|
||||
"""Test that ID counter continues across multiple trees."""
|
||||
trees = [
|
||||
{"tree1": {}},
|
||||
{"tree2": {}},
|
||||
{"tree3": {}}
|
||||
]
|
||||
nodes, edges = from_nested_dict(trees)
|
||||
|
||||
ids = [node["id"] for node in nodes]
|
||||
assert ids == [1, 2, 3]
|
||||
|
||||
|
||||
class TestFromTreeWithMetadata:
|
||||
def test_i_can_convert_single_node_with_metadata(self):
|
||||
"""Test basic conversion with explicit id and label."""
|
||||
trees = [{"id": "root", "label": "Root Node"}]
|
||||
nodes, edges = from_tree_with_metadata(trees)
|
||||
|
||||
assert len(nodes) == 1
|
||||
assert nodes[0] == {"id": "root", "label": "Root Node"}
|
||||
assert len(edges) == 0
|
||||
|
||||
def test_i_can_preserve_string_ids_from_metadata(self):
|
||||
"""Test that string IDs from the tree are preserved."""
|
||||
trees = [
|
||||
{
|
||||
"id": "root_id",
|
||||
"label": "Root",
|
||||
"children": [
|
||||
{"id": "child_id", "label": "Child"}
|
||||
]
|
||||
}
|
||||
]
|
||||
nodes, edges = from_tree_with_metadata(trees)
|
||||
|
||||
assert nodes[0]["id"] == "root_id"
|
||||
assert nodes[1]["id"] == "child_id"
|
||||
assert edges[0] == {"from": "root_id", "to": "child_id"}
|
||||
|
||||
def test_i_can_auto_increment_when_id_is_missing(self):
|
||||
"""Test fallback to auto-increment when ID is not provided."""
|
||||
trees = [
|
||||
{
|
||||
"label": "Root",
|
||||
"children": [
|
||||
{"label": "Child1"},
|
||||
{"id": "child2_id", "label": "Child2"}
|
||||
]
|
||||
}
|
||||
]
|
||||
nodes, edges = from_tree_with_metadata(trees)
|
||||
|
||||
assert nodes[0]["id"] == 1 # auto-incremented
|
||||
assert nodes[1]["id"] == 2 # auto-incremented
|
||||
assert nodes[2]["id"] == "child2_id" # preserved
|
||||
|
||||
def test_i_can_convert_tree_with_children(self):
|
||||
"""Test handling of children list."""
|
||||
trees = [
|
||||
{
|
||||
"id": "root",
|
||||
"label": "Root",
|
||||
"children": [
|
||||
{
|
||||
"id": "child1",
|
||||
"label": "Child 1",
|
||||
"children": [
|
||||
{"id": "grandchild", "label": "Grandchild"}
|
||||
]
|
||||
},
|
||||
{"id": "child2", "label": "Child 2"}
|
||||
]
|
||||
}
|
||||
]
|
||||
nodes, edges = from_tree_with_metadata(trees)
|
||||
|
||||
assert len(nodes) == 4
|
||||
assert len(edges) == 3
|
||||
assert {"from": "root", "to": "child1"} in edges
|
||||
assert {"from": "root", "to": "child2"} in edges
|
||||
assert {"from": "child1", "to": "grandchild"} in edges
|
||||
|
||||
def test_i_can_use_custom_id_getter(self):
|
||||
"""Test custom callback for extracting node ID."""
|
||||
trees = [
|
||||
{
|
||||
"node_id": "custom_root",
|
||||
"label": "Root"
|
||||
}
|
||||
]
|
||||
|
||||
def custom_id_getter(node):
|
||||
return node.get("node_id")
|
||||
|
||||
nodes, edges = from_tree_with_metadata(
|
||||
trees,
|
||||
id_getter=custom_id_getter
|
||||
)
|
||||
|
||||
assert nodes[0]["id"] == "custom_root"
|
||||
|
||||
def test_i_can_use_custom_label_getter(self):
|
||||
"""Test custom callback for extracting node label."""
|
||||
trees = [
|
||||
{
|
||||
"id": "root",
|
||||
"name": "Custom Label"
|
||||
}
|
||||
]
|
||||
|
||||
def custom_label_getter(node):
|
||||
return node.get("name", "")
|
||||
|
||||
nodes, edges = from_tree_with_metadata(
|
||||
trees,
|
||||
label_getter=custom_label_getter
|
||||
)
|
||||
|
||||
assert nodes[0]["label"] == "Custom Label"
|
||||
|
||||
def test_i_can_use_custom_children_getter(self):
|
||||
"""Test custom callback for extracting children."""
|
||||
trees = [
|
||||
{
|
||||
"id": "root",
|
||||
"label": "Root",
|
||||
"kids": [
|
||||
{"id": "child", "label": "Child"}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
def custom_children_getter(node):
|
||||
return node.get("kids", [])
|
||||
|
||||
nodes, edges = from_tree_with_metadata(
|
||||
trees,
|
||||
children_getter=custom_children_getter
|
||||
)
|
||||
|
||||
assert len(nodes) == 2
|
||||
assert nodes[1]["id"] == "child"
|
||||
|
||||
def test_i_can_handle_missing_label_with_default(self):
|
||||
"""Test that missing label returns empty string."""
|
||||
trees = [{"id": "root"}]
|
||||
nodes, edges = from_tree_with_metadata(trees)
|
||||
|
||||
assert nodes[0]["label"] == ""
|
||||
|
||||
def test_i_can_handle_missing_children_with_default(self):
|
||||
"""Test that missing children returns empty list (no children processed)."""
|
||||
trees = [{"id": "root", "label": "Root"}]
|
||||
nodes, edges = from_tree_with_metadata(trees)
|
||||
|
||||
assert len(nodes) == 1
|
||||
assert len(edges) == 0
|
||||
|
||||
def test_i_can_convert_multiple_root_trees(self):
|
||||
"""Test conversion with multiple independent trees with metadata."""
|
||||
trees = [
|
||||
{
|
||||
"id": "root1",
|
||||
"label": "Root 1",
|
||||
"children": [
|
||||
{"id": "child1", "label": "Child 1"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "root2",
|
||||
"label": "Root 2",
|
||||
"children": [
|
||||
{"id": "child2", "label": "Child 2"}
|
||||
]
|
||||
}
|
||||
]
|
||||
nodes, edges = from_tree_with_metadata(trees)
|
||||
|
||||
assert len(nodes) == 4
|
||||
assert nodes[0]["id"] == "root1"
|
||||
assert nodes[1]["id"] == "child1"
|
||||
assert nodes[2]["id"] == "root2"
|
||||
assert nodes[3]["id"] == "child2"
|
||||
|
||||
# Verify edges connect within trees, not across
|
||||
assert len(edges) == 2
|
||||
assert {"from": "root1", "to": "child1"} in edges
|
||||
assert {"from": "root2", "to": "child2"} in edges
|
||||
|
||||
def test_i_can_maintain_id_counter_across_multiple_trees_when_missing_ids(self):
|
||||
"""Test that auto-increment counter continues across multiple trees."""
|
||||
trees = [
|
||||
{"label": "Tree1"},
|
||||
{"label": "Tree2"},
|
||||
{"label": "Tree3"}
|
||||
]
|
||||
nodes, edges = from_tree_with_metadata(trees)
|
||||
|
||||
assert nodes[0]["id"] == 1
|
||||
assert nodes[1]["id"] == 2
|
||||
assert nodes[2]["id"] == 3
|
||||
|
||||
def test_i_can_convert_empty_list(self):
|
||||
"""Test that empty list returns empty nodes and edges."""
|
||||
trees = []
|
||||
nodes, edges = from_tree_with_metadata(trees)
|
||||
|
||||
assert nodes == []
|
||||
assert edges == []
|
||||
|
||||
|
||||
class TestFromParentChildList:
|
||||
def test_i_can_convert_single_root_node_without_parent(self):
|
||||
"""Test conversion of a single root node without parent."""
|
||||
items = [{"id": "root", "label": "Root"}]
|
||||
nodes, edges = from_parent_child_list(items)
|
||||
|
||||
assert len(nodes) == 1
|
||||
assert nodes[0] == {"id": "root", "label": "Root"}
|
||||
assert len(edges) == 0
|
||||
|
||||
def test_i_can_convert_simple_parent_child_relationship(self):
|
||||
"""Test conversion with basic parent-child relationship."""
|
||||
items = [
|
||||
{"id": "root", "label": "Root"},
|
||||
{"id": "child", "parent": "root", "label": "Child"}
|
||||
]
|
||||
nodes, edges = from_parent_child_list(items)
|
||||
|
||||
assert len(nodes) == 2
|
||||
assert {"id": "root", "label": "Root"} in nodes
|
||||
assert {"id": "child", "label": "Child"} in nodes
|
||||
|
||||
assert len(edges) == 1
|
||||
assert edges[0] == {"from": "root", "to": "child"}
|
||||
|
||||
def test_i_can_convert_multiple_children_with_same_parent(self):
|
||||
"""Test that one parent can have multiple children."""
|
||||
items = [
|
||||
{"id": "root", "label": "Root"},
|
||||
{"id": "child1", "parent": "root", "label": "Child 1"},
|
||||
{"id": "child2", "parent": "root", "label": "Child 2"}
|
||||
]
|
||||
nodes, edges = from_parent_child_list(items)
|
||||
|
||||
assert len(nodes) == 3
|
||||
assert len(edges) == 2
|
||||
assert {"from": "root", "to": "child1"} in edges
|
||||
assert {"from": "root", "to": "child2"} in edges
|
||||
|
||||
def test_i_can_convert_multi_level_hierarchy(self):
|
||||
"""Test conversion with multiple levels (root -> child -> grandchild)."""
|
||||
items = [
|
||||
{"id": "root", "label": "Root"},
|
||||
{"id": "child", "parent": "root", "label": "Child"},
|
||||
{"id": "grandchild", "parent": "child", "label": "Grandchild"}
|
||||
]
|
||||
nodes, edges = from_parent_child_list(items)
|
||||
|
||||
assert len(nodes) == 3
|
||||
assert len(edges) == 2
|
||||
assert {"from": "root", "to": "child"} in edges
|
||||
assert {"from": "child", "to": "grandchild"} in edges
|
||||
|
||||
def test_i_can_handle_parent_none_as_root(self):
|
||||
"""Test that parent=None identifies a root node."""
|
||||
items = [
|
||||
{"id": "root", "parent": None, "label": "Root"},
|
||||
{"id": "child", "parent": "root", "label": "Child"}
|
||||
]
|
||||
nodes, edges = from_parent_child_list(items)
|
||||
|
||||
assert len(nodes) == 2
|
||||
assert len(edges) == 1
|
||||
assert edges[0] == {"from": "root", "to": "child"}
|
||||
|
||||
def test_i_can_handle_parent_empty_string_as_root(self):
|
||||
"""Test that parent='' identifies a root node."""
|
||||
items = [
|
||||
{"id": "root", "parent": "", "label": "Root"},
|
||||
{"id": "child", "parent": "root", "label": "Child"}
|
||||
]
|
||||
nodes, edges = from_parent_child_list(items)
|
||||
|
||||
assert len(nodes) == 2
|
||||
assert len(edges) == 1
|
||||
assert edges[0] == {"from": "root", "to": "child"}
|
||||
|
||||
def test_i_can_create_ghost_node_for_missing_parent(self):
|
||||
"""Test automatic creation of ghost node when parent doesn't exist."""
|
||||
items = [
|
||||
{"id": "child", "parent": "missing_parent", "label": "Child"}
|
||||
]
|
||||
nodes, edges = from_parent_child_list(items)
|
||||
|
||||
assert len(nodes) == 2
|
||||
# Find the ghost node
|
||||
ghost_node = [n for n in nodes if n["id"] == "missing_parent"][0]
|
||||
assert ghost_node is not None
|
||||
|
||||
assert len(edges) == 1
|
||||
assert edges[0] == {"from": "missing_parent", "to": "child"}
|
||||
|
||||
def test_i_can_apply_ghost_color_to_missing_parent(self):
|
||||
"""Test that ghost nodes have the default ghost color."""
|
||||
items = [
|
||||
{"id": "child", "parent": "ghost", "label": "Child"}
|
||||
]
|
||||
nodes, edges = from_parent_child_list(items)
|
||||
|
||||
ghost_node = [n for n in nodes if n["id"] == "ghost"][0]
|
||||
assert "color" in ghost_node
|
||||
assert ghost_node["color"] == "#ff9999"
|
||||
|
||||
def test_i_can_use_custom_ghost_color(self):
|
||||
"""Test that custom ghost_color parameter is applied."""
|
||||
items = [
|
||||
{"id": "child", "parent": "ghost", "label": "Child"}
|
||||
]
|
||||
nodes, edges = from_parent_child_list(items, ghost_color="#0000ff")
|
||||
|
||||
ghost_node = [n for n in nodes if n["id"] == "ghost"][0]
|
||||
assert ghost_node["color"] == "#0000ff"
|
||||
|
||||
def test_i_can_create_multiple_ghost_nodes(self):
|
||||
"""Test handling of multiple missing parents."""
|
||||
items = [
|
||||
{"id": "child1", "parent": "ghost1", "label": "Child 1"},
|
||||
{"id": "child2", "parent": "ghost2", "label": "Child 2"}
|
||||
]
|
||||
nodes, edges = from_parent_child_list(items)
|
||||
|
||||
assert len(nodes) == 4 # 2 real + 2 ghost
|
||||
ghost_ids = [n["id"] for n in nodes if "color" in n]
|
||||
assert "ghost1" in ghost_ids
|
||||
assert "ghost2" in ghost_ids
|
||||
|
||||
def test_i_can_avoid_duplicate_ghost_nodes(self):
|
||||
"""Test that same missing parent creates only one ghost node."""
|
||||
items = [
|
||||
{"id": "child1", "parent": "ghost", "label": "Child 1"},
|
||||
{"id": "child2", "parent": "ghost", "label": "Child 2"}
|
||||
]
|
||||
nodes, edges = from_parent_child_list(items)
|
||||
|
||||
assert len(nodes) == 3 # 2 real + 1 ghost
|
||||
ghost_nodes = [n for n in nodes if n["id"] == "ghost"]
|
||||
assert len(ghost_nodes) == 1
|
||||
|
||||
assert len(edges) == 2
|
||||
assert {"from": "ghost", "to": "child1"} in edges
|
||||
assert {"from": "ghost", "to": "child2"} in edges
|
||||
|
||||
def test_i_can_use_custom_id_getter(self):
|
||||
"""Test custom callback for extracting node ID."""
|
||||
items = [
|
||||
{"node_id": "root", "label": "Root"}
|
||||
]
|
||||
|
||||
def custom_id_getter(item):
|
||||
return item.get("node_id")
|
||||
|
||||
nodes, edges = from_parent_child_list(
|
||||
items,
|
||||
id_getter=custom_id_getter
|
||||
)
|
||||
|
||||
assert nodes[0]["id"] == "root"
|
||||
|
||||
def test_i_can_use_custom_label_getter(self):
|
||||
"""Test custom callback for extracting node label."""
|
||||
items = [
|
||||
{"id": "root", "name": "Custom Label"}
|
||||
]
|
||||
|
||||
def custom_label_getter(item):
|
||||
return item.get("name", "")
|
||||
|
||||
nodes, edges = from_parent_child_list(
|
||||
items,
|
||||
label_getter=custom_label_getter
|
||||
)
|
||||
|
||||
assert nodes[0]["label"] == "Custom Label"
|
||||
|
||||
def test_i_can_use_custom_parent_getter(self):
|
||||
"""Test custom callback for extracting parent ID."""
|
||||
items = [
|
||||
{"id": "root", "label": "Root"},
|
||||
{"id": "child", "parent_id": "root", "label": "Child"}
|
||||
]
|
||||
|
||||
def custom_parent_getter(item):
|
||||
return item.get("parent_id")
|
||||
|
||||
nodes, edges = from_parent_child_list(
|
||||
items,
|
||||
parent_getter=custom_parent_getter
|
||||
)
|
||||
|
||||
assert len(edges) == 1
|
||||
assert edges[0] == {"from": "root", "to": "child"}
|
||||
|
||||
def test_i_can_handle_empty_list(self):
|
||||
"""Test that empty list returns empty nodes and edges."""
|
||||
items = []
|
||||
nodes, edges = from_parent_child_list(items)
|
||||
|
||||
assert nodes == []
|
||||
assert edges == []
|
||||
|
||||
def test_i_can_use_id_as_label_for_ghost_nodes(self):
|
||||
"""Test that ghost nodes use their ID as label by default."""
|
||||
items = [
|
||||
{"id": "child", "parent": "ghost_parent", "label": "Child"}
|
||||
]
|
||||
nodes, edges = from_parent_child_list(items)
|
||||
|
||||
ghost_node = [n for n in nodes if n["id"] == "ghost_parent"][0]
|
||||
assert ghost_node["label"] == "ghost_parent"
|
||||
Reference in New Issue
Block a user