mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-08-29 04:26:20 +00:00
KGVisualizer.visualize_network() (and sibling methods) only accepted a raw dict. Passing a KnowledgeGraph object — the natural output of GraphBuilder.build() — silently returned without rendering. Added _normalize_graph() which duck-types the input: dicts pass through unchanged; any object exposing .entities / .relationships attributes is converted to the canonical dict form; anything else raises a clear ProcessingError naming the offending type. _normalize_graph() is called as the first statement in visualize_network(), visualize_communities(), visualize_centrality(), visualize_entity_types(), and visualize_relationship_matrix(). Also adds 21 tests in tests/visualization/test_kg_visualizer_normalize_graph.py covering the helper directly, the end-to-end regression for #458, and a guard that every public method routes through _normalize_graph. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
288 lines
11 KiB
Python
288 lines
11 KiB
Python
"""
|
|
Tests for KGVisualizer._normalize_graph() and the fix for issue #458:
|
|
"KGVisualizer.visualize_network() does not accept a KnowledgeGraph object"
|
|
|
|
All public visualize_* methods must accept either:
|
|
- a plain dict {"entities": [...], "relationships": [...]}
|
|
- any object exposing .entities / .relationships attributes
|
|
and must raise a clear ProcessingError for anything else.
|
|
"""
|
|
|
|
import contextlib
|
|
import sys
|
|
import unittest
|
|
from dataclasses import dataclass, field
|
|
from typing import List
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Stub out heavy optional deps before importing the module under test
|
|
# ---------------------------------------------------------------------------
|
|
sys.modules.setdefault("matplotlib", MagicMock())
|
|
sys.modules.setdefault("matplotlib.pyplot", MagicMock())
|
|
sys.modules.setdefault("matplotlib.patches", MagicMock())
|
|
sys.modules.setdefault("plotly", MagicMock())
|
|
sys.modules.setdefault("plotly.express", MagicMock())
|
|
sys.modules.setdefault("plotly.graph_objects", MagicMock())
|
|
sys.modules.setdefault("plotly.subplots", MagicMock())
|
|
sys.modules.setdefault("graphviz", MagicMock())
|
|
sys.modules.setdefault("seaborn", MagicMock())
|
|
|
|
from semantica.utils.exceptions import ProcessingError # noqa: E402
|
|
from semantica.visualization.kg_visualizer import KGVisualizer # noqa: E402
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Minimal fixtures
|
|
# ---------------------------------------------------------------------------
|
|
ENTITIES = [
|
|
{"id": "e1", "text": "Alice", "type": "Person"},
|
|
{"id": "e2", "text": "Bob", "type": "Person"},
|
|
]
|
|
RELATIONSHIPS = [
|
|
{"source": "e1", "target": "e2", "type": "KNOWS"},
|
|
]
|
|
GRAPH_DICT = {"entities": ENTITIES, "relationships": RELATIONSHIPS}
|
|
|
|
|
|
@dataclass
|
|
class SimpleKG:
|
|
"""Minimal KnowledgeGraph-like dataclass (mimics GraphBuilder output)."""
|
|
entities: List[dict] = field(default_factory=list)
|
|
relationships: List[dict] = field(default_factory=list)
|
|
metadata: dict = field(default_factory=dict)
|
|
|
|
|
|
class NamespaceKG:
|
|
"""Object-with-attributes variant (no dataclass decorator)."""
|
|
def __init__(self, entities, relationships, metadata=None):
|
|
self.entities = entities
|
|
self.relationships = relationships
|
|
self.metadata = metadata or {}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helper: build a KGVisualizer with all heavy internals mocked out
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _make_viz():
|
|
mock_logger = MagicMock()
|
|
mock_tracker = MagicMock()
|
|
mock_tracker.enabled = True
|
|
mock_tracker.start_tracking.return_value = "tid"
|
|
|
|
patches = [
|
|
patch("semantica.visualization.kg_visualizer.get_logger", return_value=mock_logger),
|
|
patch("semantica.visualization.kg_visualizer.get_progress_tracker", return_value=mock_tracker),
|
|
patch("semantica.visualization.kg_visualizer.ForceDirectedLayout", MagicMock()),
|
|
patch("semantica.visualization.kg_visualizer.HierarchicalLayout", MagicMock()),
|
|
patch("semantica.visualization.kg_visualizer.CircularLayout", MagicMock()),
|
|
]
|
|
with contextlib.ExitStack() as stack:
|
|
for p in patches:
|
|
stack.enter_context(p)
|
|
viz = KGVisualizer(layout="force")
|
|
|
|
viz.logger = mock_logger
|
|
viz.progress_tracker = mock_tracker
|
|
return viz
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests for _normalize_graph directly
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestNormalizeGraph(unittest.TestCase):
|
|
"""Unit tests for _normalize_graph — no Plotly calls needed."""
|
|
|
|
def setUp(self):
|
|
self.viz = _make_viz()
|
|
|
|
# --- dict input ---
|
|
|
|
def test_dict_passthrough(self):
|
|
result = self.viz._normalize_graph(GRAPH_DICT)
|
|
self.assertIs(result, GRAPH_DICT, "_normalize_graph should return the same dict unchanged")
|
|
|
|
def test_dict_missing_keys_passthrough(self):
|
|
"""A dict without entities/relationships is still passed through; callers handle emptiness."""
|
|
result = self.viz._normalize_graph({})
|
|
self.assertIsInstance(result, dict)
|
|
|
|
# --- object-with-attributes input ---
|
|
|
|
def test_dataclass_kg(self):
|
|
kg = SimpleKG(entities=ENTITIES, relationships=RELATIONSHIPS)
|
|
result = self.viz._normalize_graph(kg)
|
|
self.assertEqual(result["entities"], ENTITIES)
|
|
self.assertEqual(result["relationships"], RELATIONSHIPS)
|
|
|
|
def test_namespace_kg(self):
|
|
kg = NamespaceKG(entities=ENTITIES, relationships=RELATIONSHIPS)
|
|
result = self.viz._normalize_graph(kg)
|
|
self.assertEqual(result["entities"], ENTITIES)
|
|
self.assertEqual(result["relationships"], RELATIONSHIPS)
|
|
|
|
def test_object_with_only_entities(self):
|
|
"""An object with only .entities (no .relationships) should still work."""
|
|
class EntitiesOnly:
|
|
entities = ENTITIES
|
|
result = self.viz._normalize_graph(EntitiesOnly())
|
|
self.assertEqual(result["entities"], ENTITIES)
|
|
self.assertEqual(result["relationships"], [])
|
|
|
|
def test_object_with_only_relationships(self):
|
|
"""An object with only .relationships (no .entities) should still work."""
|
|
class RelsOnly:
|
|
relationships = RELATIONSHIPS
|
|
result = self.viz._normalize_graph(RelsOnly())
|
|
self.assertEqual(result["entities"], [])
|
|
self.assertEqual(result["relationships"], RELATIONSHIPS)
|
|
|
|
def test_metadata_propagated(self):
|
|
kg = SimpleKG(entities=ENTITIES, relationships=RELATIONSHIPS, metadata={"version": "1"})
|
|
result = self.viz._normalize_graph(kg)
|
|
self.assertEqual(result["metadata"], {"version": "1"})
|
|
|
|
def test_metadata_defaults_to_empty_dict(self):
|
|
kg = NamespaceKG(entities=ENTITIES, relationships=RELATIONSHIPS)
|
|
kg.metadata = None
|
|
result = self.viz._normalize_graph(kg)
|
|
self.assertEqual(result["metadata"], {})
|
|
|
|
# --- unsupported types ---
|
|
|
|
def test_raises_for_string(self):
|
|
with self.assertRaises(ProcessingError) as ctx:
|
|
self.viz._normalize_graph("not a graph")
|
|
self.assertIn("str", str(ctx.exception))
|
|
|
|
def test_raises_for_integer(self):
|
|
with self.assertRaises(ProcessingError):
|
|
self.viz._normalize_graph(42)
|
|
|
|
def test_raises_for_list(self):
|
|
with self.assertRaises(ProcessingError):
|
|
self.viz._normalize_graph([{"id": "e1"}])
|
|
|
|
def test_raises_for_none(self):
|
|
with self.assertRaises((ProcessingError, AttributeError)):
|
|
self.viz._normalize_graph(None)
|
|
|
|
def test_error_message_names_type(self):
|
|
class WeirdThing:
|
|
pass
|
|
with self.assertRaises(ProcessingError) as ctx:
|
|
self.viz._normalize_graph(WeirdThing())
|
|
self.assertIn("WeirdThing", str(ctx.exception))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Integration: visualize_network accepts KG objects end-to-end
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestVisualizeNetworkAcceptsKGObject(unittest.TestCase):
|
|
"""
|
|
Regression tests for issue #458.
|
|
|
|
visualize_network() must produce the same result whether it receives a
|
|
dict or an equivalent KG object.
|
|
"""
|
|
|
|
def _run_visualize_network(self, graph_arg):
|
|
"""Run visualize_network with all Plotly internals mocked."""
|
|
mock_fig = MagicMock()
|
|
mock_go = sys.modules["plotly.graph_objects"]
|
|
mock_go.Figure.return_value = mock_fig
|
|
mock_go.Scatter.return_value = MagicMock()
|
|
mock_go.Layout.return_value = MagicMock()
|
|
|
|
viz = _make_viz()
|
|
|
|
# Mock layout to return deterministic positions
|
|
fake_pos = {"e1": (0.0, 0.0), "e2": (1.0, 1.0)}
|
|
viz.force_layout = MagicMock()
|
|
viz.force_layout.compute_layout.return_value = fake_pos
|
|
viz.hierarchical_layout = MagicMock()
|
|
viz.circular_layout = MagicMock()
|
|
|
|
# ColorPalette helpers
|
|
with (
|
|
patch(
|
|
"semantica.visualization.kg_visualizer.ColorPalette.get_entity_type_colors",
|
|
return_value={"Person": "#ff0000"},
|
|
),
|
|
patch(
|
|
"semantica.visualization.kg_visualizer.ColorPalette.get_colors",
|
|
return_value=["#ff0000"],
|
|
),
|
|
):
|
|
return viz.visualize_network(graph_arg, output="interactive")
|
|
|
|
def test_dict_input_returns_figure(self):
|
|
fig = self._run_visualize_network(GRAPH_DICT)
|
|
self.assertIsNotNone(fig)
|
|
|
|
def test_dataclass_kg_returns_figure(self):
|
|
"""Issue #458: passing a KnowledgeGraph dataclass must not be a silent no-op."""
|
|
kg = SimpleKG(entities=ENTITIES, relationships=RELATIONSHIPS)
|
|
fig = self._run_visualize_network(kg)
|
|
self.assertIsNotNone(fig)
|
|
|
|
def test_namespace_kg_returns_figure(self):
|
|
kg = NamespaceKG(entities=ENTITIES, relationships=RELATIONSHIPS)
|
|
fig = self._run_visualize_network(kg)
|
|
self.assertIsNotNone(fig)
|
|
|
|
def test_unsupported_type_raises_processing_error(self):
|
|
viz = _make_viz()
|
|
with self.assertRaises(ProcessingError):
|
|
viz.visualize_network("not a graph")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Integration: all other visualize_* methods also accept KG objects
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestAllVisualizeMethodsAcceptKGObject(unittest.TestCase):
|
|
"""Each public visualize_* method must call _normalize_graph."""
|
|
|
|
def setUp(self):
|
|
self.viz = _make_viz()
|
|
self.kg = SimpleKG(entities=ENTITIES, relationships=RELATIONSHIPS)
|
|
|
|
def test_visualize_communities_accepts_kg_object(self):
|
|
self.viz._normalize_graph = MagicMock(return_value=GRAPH_DICT)
|
|
self.viz._visualize_network_plotly = MagicMock(return_value=MagicMock())
|
|
communities = {"node_assignments": {"e1": 0, "e2": 1}, "num_communities": 2}
|
|
with patch(
|
|
"semantica.visualization.kg_visualizer.ColorPalette.get_community_colors",
|
|
return_value=["#ff0000", "#00ff00"],
|
|
):
|
|
self.viz.visualize_communities(self.kg, communities=communities)
|
|
self.viz._normalize_graph.assert_called_once_with(self.kg)
|
|
|
|
def test_visualize_centrality_accepts_kg_object(self):
|
|
self.viz._normalize_graph = MagicMock(return_value=GRAPH_DICT)
|
|
self.viz._visualize_network_plotly = MagicMock(return_value=MagicMock())
|
|
self.viz.visualize_centrality(self.kg, centrality={"centrality": {}})
|
|
self.viz._normalize_graph.assert_called_once_with(self.kg)
|
|
|
|
def test_visualize_entity_types_accepts_kg_object(self):
|
|
self.viz._normalize_graph = MagicMock(return_value=GRAPH_DICT)
|
|
mock_px = sys.modules["plotly.express"]
|
|
mock_px.bar.return_value = MagicMock()
|
|
self.viz.visualize_entity_types(self.kg)
|
|
self.viz._normalize_graph.assert_called_once_with(self.kg)
|
|
|
|
def test_visualize_relationship_matrix_accepts_kg_object(self):
|
|
self.viz._normalize_graph = MagicMock(return_value=GRAPH_DICT)
|
|
mock_go = sys.modules["plotly.graph_objects"]
|
|
mock_go.Figure.return_value = MagicMock()
|
|
mock_go.Heatmap.return_value = MagicMock()
|
|
self.viz.visualize_relationship_matrix(self.kg)
|
|
self.viz._normalize_graph.assert_called_once_with(self.kg)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|