PlanOpticon

planopticon / tests / test_graph_query.py
Blame History Raw 338 lines
1
"""Tests for graph query engine."""
2
3
import json
4
from unittest.mock import MagicMock
5
6
import pytest
7
8
from video_processor.integrators.graph_query import GraphQueryEngine, QueryResult
9
from video_processor.integrators.graph_store import InMemoryStore, SQLiteStore
10
11
12
def _make_populated_store():
13
"""Create a store with test data."""
14
store = InMemoryStore()
15
store.merge_entity("Python", "technology", ["A programming language"])
16
store.merge_entity("Django", "technology", ["A web framework"])
17
store.merge_entity("Alice", "person", ["Software engineer"])
18
store.merge_entity("Bob", "person", ["Product manager"])
19
store.merge_entity("Acme Corp", "organization", ["A tech company"])
20
store.add_relationship("Alice", "Python", "uses")
21
store.add_relationship("Alice", "Bob", "works_with")
22
store.add_relationship("Django", "Python", "built_on")
23
store.add_relationship("Alice", "Acme Corp", "employed_by")
24
return store
25
26
27
class TestQueryResultToText:
28
def test_text_with_dict_data(self):
29
r = QueryResult(
30
data={"entity_count": 5, "relationship_count": 3},
31
query_type="filter",
32
explanation="Stats",
33
)
34
text = r.to_text()
35
assert "entity_count: 5" in text
36
assert "relationship_count: 3" in text
37
38
def test_text_with_list_of_entities(self):
39
r = QueryResult(
40
data=[{"name": "Python", "type": "technology", "descriptions": ["A language"]}],
41
query_type="filter",
42
)
43
text = r.to_text()
44
assert "Python" in text
45
assert "technology" in text
46
47
def test_text_with_empty_list(self):
48
r = QueryResult(data=[], query_type="filter")
49
assert "No results" in r.to_text()
50
51
def test_text_with_relationships(self):
52
r = QueryResult(
53
data=[{"source": "A", "target": "B", "type": "knows"}],
54
query_type="filter",
55
)
56
text = r.to_text()
57
assert "A" in text
58
assert "B" in text
59
assert "knows" in text
60
61
62
class TestQueryResultToJson:
63
def test_json_roundtrip(self):
64
r = QueryResult(data={"key": "val"}, query_type="filter", raw_query="test()")
65
parsed = json.loads(r.to_json())
66
assert parsed["query_type"] == "filter"
67
assert parsed["data"]["key"] == "val"
68
assert parsed["raw_query"] == "test()"
69
70
71
class TestQueryResultToMermaid:
72
def test_mermaid_with_entities_and_rels(self):
73
r = QueryResult(
74
data=[
75
{"name": "Alice", "type": "person"},
76
{"name": "Bob", "type": "person"},
77
{"source": "Alice", "target": "Bob", "type": "knows"},
78
],
79
query_type="filter",
80
)
81
mermaid = r.to_mermaid()
82
assert "graph LR" in mermaid
83
assert "Alice" in mermaid
84
assert "Bob" in mermaid
85
assert "knows" in mermaid
86
87
def test_mermaid_empty(self):
88
r = QueryResult(data=[], query_type="filter")
89
mermaid = r.to_mermaid()
90
assert "graph LR" in mermaid
91
92
93
class TestDirectMode:
94
def test_stats(self):
95
store = _make_populated_store()
96
engine = GraphQueryEngine(store)
97
result = engine.stats()
98
assert result.data["entity_count"] == 5
99
assert result.data["relationship_count"] == 4
100
assert result.data["entity_types"]["technology"] == 2
101
assert result.data["entity_types"]["person"] == 2
102
103
def test_entities_no_filter(self):
104
store = _make_populated_store()
105
engine = GraphQueryEngine(store)
106
result = engine.entities()
107
assert len(result.data) == 5
108
109
def test_entities_filter_by_name(self):
110
store = _make_populated_store()
111
engine = GraphQueryEngine(store)
112
result = engine.entities(name="python")
113
assert len(result.data) == 1
114
assert result.data[0]["name"] == "Python"
115
116
def test_entities_filter_by_type(self):
117
store = _make_populated_store()
118
engine = GraphQueryEngine(store)
119
result = engine.entities(entity_type="person")
120
assert len(result.data) == 2
121
names = {e["name"] for e in result.data}
122
assert names == {"Alice", "Bob"}
123
124
def test_entities_filter_by_both(self):
125
store = _make_populated_store()
126
engine = GraphQueryEngine(store)
127
result = engine.entities(name="ali", entity_type="person")
128
assert len(result.data) == 1
129
assert result.data[0]["name"] == "Alice"
130
131
def test_entities_case_insensitive(self):
132
store = _make_populated_store()
133
engine = GraphQueryEngine(store)
134
result = engine.entities(name="PYTHON")
135
assert len(result.data) == 1
136
137
def test_relationships_no_filter(self):
138
store = _make_populated_store()
139
engine = GraphQueryEngine(store)
140
result = engine.relationships()
141
assert len(result.data) == 4
142
143
def test_relationships_filter_by_source(self):
144
store = _make_populated_store()
145
engine = GraphQueryEngine(store)
146
result = engine.relationships(source="alice")
147
assert len(result.data) == 3
148
149
def test_relationships_filter_by_type(self):
150
store = _make_populated_store()
151
engine = GraphQueryEngine(store)
152
result = engine.relationships(rel_type="uses")
153
assert len(result.data) == 1
154
155
def test_neighbors(self):
156
store = _make_populated_store()
157
engine = GraphQueryEngine(store)
158
result = engine.neighbors("Alice")
159
# Alice connects to Python, Bob, Acme Corp
160
entities = [item for item in result.data if "name" in item]
161
rels = [item for item in result.data if "source" in item and "target" in item]
162
assert len(entities) >= 2 # Alice + neighbors
163
assert len(rels) >= 1
164
165
def test_neighbors_not_found(self):
166
store = _make_populated_store()
167
engine = GraphQueryEngine(store)
168
result = engine.neighbors("Ghost")
169
assert result.data == []
170
assert "not found" in result.explanation
171
172
def test_sql_raises_on_inmemory(self):
173
store = InMemoryStore()
174
engine = GraphQueryEngine(store)
175
with pytest.raises(NotImplementedError):
176
engine.sql("SELECT * FROM entities")
177
178
def test_entities_limit(self):
179
store = _make_populated_store()
180
engine = GraphQueryEngine(store)
181
result = engine.entities(limit=2)
182
assert len(result.data) == 2
183
184
185
class TestFromJsonPath:
186
def test_load_from_json(self, tmp_path):
187
data = {
188
"nodes": [
189
{"name": "Python", "type": "technology", "descriptions": ["A language"]},
190
{"name": "Alice", "type": "person", "descriptions": ["Engineer"]},
191
],
192
"relationships": [
193
{"source": "Alice", "target": "Python", "type": "uses"},
194
],
195
}
196
jf = tmp_path / "kg.json"
197
jf.write_text(json.dumps(data))
198
engine = GraphQueryEngine.from_json_path(jf)
199
result = engine.stats()
200
assert result.data["entity_count"] == 2
201
assert result.data["relationship_count"] == 1
202
203
204
class TestSQLiteQuery:
205
def test_sql_passthrough(self, tmp_path):
206
store = SQLiteStore(tmp_path / "test.db")
207
store.merge_entity("Python", "technology", ["A language"])
208
engine = GraphQueryEngine(store)
209
result = engine.sql("SELECT name FROM entities")
210
assert len(result.data) >= 1
211
assert result.query_type == "sql"
212
store.close()
213
214
def test_raw_query_on_store(self, tmp_path):
215
store = SQLiteStore(tmp_path / "test.db")
216
store.merge_entity("Alice", "person", ["Engineer"])
217
rows = store.raw_query("SELECT name FROM entities")
218
assert len(rows) >= 1
219
store.close()
220
221
222
class TestAgenticMode:
223
def test_ask_requires_provider(self):
224
store = _make_populated_store()
225
engine = GraphQueryEngine(store, provider_manager=None)
226
result = engine.ask("What technologies are used?")
227
assert result.query_type == "agentic"
228
assert "requires" in result.explanation.lower()
229
230
def test_ask_with_mock_llm(self):
231
store = _make_populated_store()
232
mock_pm = MagicMock()
233
# First call: plan generation — return entities action
234
# Second call: synthesis — return a summary
235
mock_pm.chat.side_effect = [
236
'{"action": "entities", "entity_type": "technology"}',
237
"The knowledge graph contains two technologies: Python and Django.",
238
]
239
engine = GraphQueryEngine(store, provider_manager=mock_pm)
240
result = engine.ask("What technologies are in the graph?")
241
assert result.query_type == "agentic"
242
assert mock_pm.chat.call_count == 2
243
assert "Python" in result.explanation or len(result.data) >= 1
244
245
def test_ask_with_stats_action(self):
246
store = _make_populated_store()
247
mock_pm = MagicMock()
248
mock_pm.chat.side_effect = [
249
'{"action": "stats"}',
250
"The graph has 5 entities and 4 relationships.",
251
]
252
engine = GraphQueryEngine(store, provider_manager=mock_pm)
253
result = engine.ask("How big is this graph?")
254
assert result.data["entity_count"] == 5
255
256
def test_ask_with_neighbors_action(self):
257
store = _make_populated_store()
258
mock_pm = MagicMock()
259
mock_pm.chat.side_effect = [
260
'{"action": "neighbors", "entity_name": "Alice"}',
261
"Alice is connected to Python, Bob, and Acme Corp.",
262
]
263
engine = GraphQueryEngine(store, provider_manager=mock_pm)
264
result = engine.ask("What is Alice connected to?")
265
assert result.query_type == "agentic"
266
assert len(result.data) > 0
267
268
def test_ask_handles_unparseable_plan(self):
269
store = _make_populated_store()
270
mock_pm = MagicMock()
271
mock_pm.chat.return_value = "I don't understand"
272
engine = GraphQueryEngine(store, provider_manager=mock_pm)
273
result = engine.ask("Gibberish?")
274
assert result.data is None
275
assert "parse" in result.explanation.lower() or "could not" in result.explanation.lower()
276
277
278
class TestGraphAlgorithms:
279
def test_shortest_path(self):
280
store = InMemoryStore()
281
store.merge_entity("A", "concept", [])
282
store.merge_entity("B", "concept", [])
283
store.merge_entity("C", "concept", [])
284
store.add_relationship("A", "B", "connects")
285
store.add_relationship("B", "C", "connects")
286
engine = GraphQueryEngine(store)
287
288
result = engine.shortest_path("A", "C")
289
assert "Path found" in result.explanation
290
assert len(result.data) > 0
291
292
def test_shortest_path_same_entity(self):
293
store = InMemoryStore()
294
store.merge_entity("X", "concept", [])
295
engine = GraphQueryEngine(store)
296
297
result = engine.shortest_path("X", "X")
298
assert "same entity" in result.explanation.lower()
299
300
def test_shortest_path_not_found(self):
301
store = InMemoryStore()
302
store.merge_entity("A", "concept", [])
303
store.merge_entity("Z", "concept", [])
304
engine = GraphQueryEngine(store)
305
306
result = engine.shortest_path("A", "Z")
307
assert "No path found" in result.explanation
308
309
def test_shortest_path_entity_missing(self):
310
store = InMemoryStore()
311
engine = GraphQueryEngine(store)
312
313
result = engine.shortest_path("Missing", "Also Missing")
314
assert "not found" in result.explanation
315
316
def test_clusters(self):
317
store = InMemoryStore()
318
store.merge_entity("A", "concept", [])
319
store.merge_entity("B", "concept", [])
320
store.add_relationship("A", "B", "connected")
321
322
store.merge_entity("X", "concept", [])
323
store.merge_entity("Y", "concept", [])
324
store.add_relationship("X", "Y", "connected")
325
326
store.merge_entity("Lone", "concept", [])
327
328
engine = GraphQueryEngine(store)
329
result = engine.clusters()
330
assert "3 clusters" in result.explanation
331
assert result.data[0]["size"] == 2
332
333
def test_clusters_empty(self):
334
store = InMemoryStore()
335
engine = GraphQueryEngine(store)
336
result = engine.clusters()
337
assert result.data == []
338

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button