Navegador

navegador / tests / test_enrichment_base.py
Blame History Raw 414 lines
1
"""Tests for navegador.enrichment — EnrichmentResult, FrameworkEnricher, and CLI."""
2
3
import json
4
from unittest.mock import MagicMock, call, patch
5
6
import pytest
7
from click.testing import CliRunner
8
9
from navegador.enrichment import EnrichmentResult, FrameworkEnricher
10
from navegador.enrichment.base import FrameworkEnricher as FrameworkEnricherBase
11
from navegador.graph.store import GraphStore
12
13
14
# ── Helpers ───────────────────────────────────────────────────────────────────
15
16
17
def _mock_store(result_set=None):
18
"""Return a GraphStore backed by a mock FalkorDB graph."""
19
client = MagicMock()
20
graph = MagicMock()
21
graph.query.return_value = MagicMock(result_set=result_set)
22
client.select_graph.return_value = graph
23
store = GraphStore(client)
24
return store
25
26
27
class MockEnricher(FrameworkEnricher):
28
"""Concrete enricher used in tests."""
29
30
@property
31
def framework_name(self) -> str:
32
return "mock"
33
34
@property
35
def detection_patterns(self) -> list[str]:
36
return ["mock_module", "mock_settings.py"]
37
38
def enrich(self) -> EnrichmentResult:
39
result = EnrichmentResult()
40
result.promoted = 3
41
result.edges_added = 2
42
result.patterns_found = {"mock_view": 3, "mock_model": 0}
43
return result
44
45
46
# ── EnrichmentResult defaults ─────────────────────────────────────────────────
47
48
49
class TestEnrichmentResult:
50
def test_promoted_defaults_to_zero(self):
51
r = EnrichmentResult()
52
assert r.promoted == 0
53
54
def test_edges_added_defaults_to_zero(self):
55
r = EnrichmentResult()
56
assert r.edges_added == 0
57
58
def test_patterns_found_defaults_to_empty_dict(self):
59
r = EnrichmentResult()
60
assert r.patterns_found == {}
61
62
def test_attributes_are_mutable(self):
63
r = EnrichmentResult()
64
r.promoted = 5
65
r.edges_added = 10
66
r.patterns_found["view"] = 7
67
assert r.promoted == 5
68
assert r.edges_added == 10
69
assert r.patterns_found["view"] == 7
70
71
def test_instances_are_independent(self):
72
r1 = EnrichmentResult()
73
r2 = EnrichmentResult()
74
r1.patterns_found["x"] = 1
75
assert "x" not in r2.patterns_found
76
77
78
# ── FrameworkEnricher.detect() ────────────────────────────────────────────────
79
80
81
class TestDetect:
82
def test_returns_true_when_pattern_matches(self):
83
store = _mock_store(result_set=[[1]])
84
enricher = MockEnricher(store)
85
assert enricher.detect() is True
86
87
def test_returns_false_when_no_match(self):
88
store = _mock_store(result_set=[[0]])
89
enricher = MockEnricher(store)
90
assert enricher.detect() is False
91
92
def test_returns_false_when_result_set_is_empty(self):
93
store = _mock_store(result_set=[])
94
enricher = MockEnricher(store)
95
assert enricher.detect() is False
96
97
def test_returns_false_when_result_set_is_none(self):
98
store = _mock_store(result_set=None)
99
enricher = MockEnricher(store)
100
assert enricher.detect() is False
101
102
def test_returns_true_on_second_pattern_if_first_misses(self):
103
"""detect() short-circuits on the first positive match, but we verify
104
it tries subsequent patterns when earlier ones return zero."""
105
call_count = 0
106
107
def _side_effect(cypher, params):
108
nonlocal call_count
109
call_count += 1
110
# First pattern returns 0, second returns 1
111
count = 1 if call_count >= 2 else 0
112
return MagicMock(result_set=[[count]])
113
114
client = MagicMock()
115
graph = MagicMock()
116
graph.query.side_effect = _side_effect
117
client.select_graph.return_value = graph
118
store = GraphStore(client)
119
120
enricher = MockEnricher(store)
121
assert enricher.detect() is True
122
assert call_count == 2
123
124
def test_detect_queries_each_pattern_with_correct_param(self):
125
store = _mock_store(result_set=[[0]])
126
enricher = MockEnricher(store)
127
enricher.detect()
128
129
calls = store._graph.query.call_args_list
130
# Two patterns → two queries
131
assert len(calls) == 2
132
params0 = calls[0][0][1] if len(calls[0][0]) > 1 else calls[0][1].get("params", {})
133
params1 = calls[1][0][1] if len(calls[1][0]) > 1 else calls[1][1].get("params", {})
134
assert params0 == {"name": "mock_module"}
135
assert params1 == {"name": "mock_settings.py"}
136
137
def test_stops_early_when_first_pattern_matches(self):
138
store = _mock_store(result_set=[[5]])
139
enricher = MockEnricher(store)
140
assert enricher.detect() is True
141
# Should only query once (short-circuit on first match)
142
assert store._graph.query.call_count == 1
143
144
145
# ── FrameworkEnricher._promote_node() ────────────────────────────────────────
146
147
148
class TestPromoteNode:
149
def test_calls_store_query_with_correct_cypher_and_params(self):
150
store = _mock_store()
151
enricher = MockEnricher(store)
152
enricher._promote_node("MyView", "app/views.py", "DjangoView")
153
154
store._graph.query.assert_called_once()
155
cypher, params = store._graph.query.call_args[0]
156
assert "SET n.semantic_type = $semantic_type" in cypher
157
assert "n.name = $name" in cypher
158
assert "n.file_path = $file_path" in cypher
159
assert params["name"] == "MyView"
160
assert params["file_path"] == "app/views.py"
161
assert params["semantic_type"] == "DjangoView"
162
163
def test_extra_props_appended_to_set_clause(self):
164
store = _mock_store()
165
enricher = MockEnricher(store)
166
enricher._promote_node(
167
"MyModel", "app/models.py", "DjangoModel", props={"table": "my_table"}
168
)
169
170
cypher, params = store._graph.query.call_args[0]
171
assert "n.table = $table" in cypher
172
assert params["table"] == "my_table"
173
174
def test_no_extra_props_produces_clean_cypher(self):
175
store = _mock_store()
176
enricher = MockEnricher(store)
177
enricher._promote_node("Fn", "a.py", "endpoint")
178
179
cypher, _ = store._graph.query.call_args[0]
180
# Should not have a trailing comma or extra SET clause pieces
181
assert cypher.count("SET") == 1
182
assert "None" not in cypher
183
184
def test_multiple_extra_props(self):
185
store = _mock_store()
186
enricher = MockEnricher(store)
187
enricher._promote_node(
188
"Router", "routes.py", "FastAPIRouter", props={"prefix": "/api", "version": "v1"}
189
)
190
191
cypher, params = store._graph.query.call_args[0]
192
assert "n.prefix = $prefix" in cypher
193
assert "n.version = $version" in cypher
194
assert params["prefix"] == "/api"
195
assert params["version"] == "v1"
196
197
198
# ── FrameworkEnricher._add_semantic_edge() ────────────────────────────────────
199
200
201
class TestAddSemanticEdge:
202
def test_calls_store_query_with_correct_cypher_and_params(self):
203
store = _mock_store()
204
enricher = MockEnricher(store)
205
enricher._add_semantic_edge("UserView", "HANDLES", "UserModel")
206
207
store._graph.query.assert_called_once()
208
cypher, params = store._graph.query.call_args[0]
209
assert "MERGE (a)-[r:HANDLES]->(b)" in cypher
210
assert "a.name = $from_name" in cypher
211
assert "b.name = $to_name" in cypher
212
assert params["from_name"] == "UserView"
213
assert params["to_name"] == "UserModel"
214
215
def test_extra_props_produce_set_clause(self):
216
store = _mock_store()
217
enricher = MockEnricher(store)
218
enricher._add_semantic_edge(
219
"ViewA", "CALLS", "ViewB", props={"weight": 1, "layer": "http"}
220
)
221
222
cypher, params = store._graph.query.call_args[0]
223
assert "SET" in cypher
224
assert "r.weight = $weight" in cypher
225
assert "r.layer = $layer" in cypher
226
assert params["weight"] == 1
227
assert params["layer"] == "http"
228
229
def test_no_extra_props_no_set_clause(self):
230
store = _mock_store()
231
enricher = MockEnricher(store)
232
enricher._add_semantic_edge("A", "LINKS", "B")
233
234
cypher, _ = store._graph.query.call_args[0]
235
assert "SET" not in cypher
236
237
def test_edge_type_is_interpolated(self):
238
store = _mock_store()
239
enricher = MockEnricher(store)
240
enricher._add_semantic_edge("X", "CUSTOM_EDGE_TYPE", "Y")
241
242
cypher, _ = store._graph.query.call_args[0]
243
assert "CUSTOM_EDGE_TYPE" in cypher
244
245
246
# ── MockEnricher.enrich() contract ────────────────────────────────────────────
247
248
249
class TestMockEnricherEnrich:
250
def test_returns_enrichment_result(self):
251
store = _mock_store()
252
enricher = MockEnricher(store)
253
result = enricher.enrich()
254
assert isinstance(result, EnrichmentResult)
255
256
def test_result_values(self):
257
store = _mock_store()
258
enricher = MockEnricher(store)
259
result = enricher.enrich()
260
assert result.promoted == 3
261
assert result.edges_added == 2
262
assert result.patterns_found == {"mock_view": 3, "mock_model": 0}
263
264
def test_framework_name(self):
265
store = _mock_store()
266
assert MockEnricher(store).framework_name == "mock"
267
268
def test_detection_patterns(self):
269
store = _mock_store()
270
patterns = MockEnricher(store).detection_patterns
271
assert "mock_module" in patterns
272
assert "mock_settings.py" in patterns
273
274
275
# ── Abstract enforcement ───────────────────────────────────────────────────────
276
277
278
class TestAbstractEnforcement:
279
def test_cannot_instantiate_base_class_directly(self):
280
store = _mock_store()
281
with pytest.raises(TypeError):
282
FrameworkEnricher(store) # type: ignore[abstract]
283
284
def test_subclass_missing_framework_name_raises(self):
285
with pytest.raises(TypeError):
286
287
class Incomplete(FrameworkEnricher):
288
@property
289
def detection_patterns(self):
290
return []
291
292
def enrich(self):
293
return EnrichmentResult()
294
295
Incomplete(_mock_store())
296
297
def test_subclass_missing_detection_patterns_raises(self):
298
with pytest.raises(TypeError):
299
300
class Incomplete(FrameworkEnricher):
301
@property
302
def framework_name(self):
303
return "x"
304
305
def enrich(self):
306
return EnrichmentResult()
307
308
Incomplete(_mock_store())
309
310
def test_subclass_missing_enrich_raises(self):
311
with pytest.raises(TypeError):
312
313
class Incomplete(FrameworkEnricher):
314
@property
315
def framework_name(self):
316
return "x"
317
318
@property
319
def detection_patterns(self):
320
return []
321
322
Incomplete(_mock_store())
323
324
325
# ── CLI: navegador enrich ──────────────────────────────────────────────────────
326
327
328
class TestEnrichCLI:
329
def _runner(self):
330
return CliRunner()
331
332
def test_no_frameworks_detected_message(self):
333
from navegador.cli.commands import main
334
335
runner = self._runner()
336
with patch("navegador.cli.commands._get_store", return_value=_mock_store()):
337
# No enricher modules in package yet — message should appear
338
result = runner.invoke(main, ["enrich"])
339
assert result.exit_code == 0
340
assert "No frameworks detected" in result.output
341
342
def test_unknown_framework_exits_nonzero(self):
343
from navegador.cli.commands import main
344
345
runner = self._runner()
346
with patch("navegador.cli.commands._get_store", return_value=_mock_store()):
347
result = runner.invoke(main, ["enrich", "--framework", "nonexistent_xyz"])
348
assert result.exit_code != 0
349
350
def test_json_flag_produces_empty_object_when_no_frameworks(self):
351
from navegador.cli.commands import main
352
353
runner = self._runner()
354
with patch("navegador.cli.commands._get_store", return_value=_mock_store()):
355
result = runner.invoke(main, ["enrich", "--json"])
356
assert result.exit_code == 0
357
data = json.loads(result.output)
358
assert isinstance(data, dict)
359
360
def test_enrich_command_exists_in_main_group(self):
361
from navegador.cli.commands import main
362
363
assert "enrich" in main.commands
364
365
def test_enrich_runs_enricher_when_framework_registered(self):
366
"""Patch the enrichment package discovery to inject MockEnricher."""
367
import pkgutil
368
369
from navegador.cli.commands import main
370
371
runner = self._runner()
372
store = _mock_store(result_set=[[1]])
373
374
fake_module = MagicMock()
375
fake_module.MockEnricher = MockEnricher
376
377
def fake_iter_modules(path):
378
yield MagicMock(), "mock_framework", False
379
380
with patch("navegador.cli.commands._get_store", return_value=store), \
381
patch("pkgutil.iter_modules", side_effect=fake_iter_modules), \
382
patch("importlib.import_module", return_value=fake_module):
383
result = runner.invoke(main, ["enrich", "--framework", "mock"])
384
385
assert result.exit_code == 0
386
assert "mock" in result.output.lower()
387
388
def test_enrich_json_output_structure(self):
389
"""Verify JSON output shape when an enricher runs."""
390
import pkgutil
391
392
from navegador.cli.commands import main
393
394
runner = self._runner()
395
store = _mock_store(result_set=[[1]])
396
397
fake_module = MagicMock()
398
fake_module.MockEnricher = MockEnricher
399
400
def fake_iter_modules(path):
401
yield MagicMock(), "mock_framework", False
402
403
with patch("navegador.cli.commands._get_store", return_value=store), \
404
patch("pkgutil.iter_modules", side_effect=fake_iter_modules), \
405
patch("importlib.import_module", return_value=fake_module):
406
result = runner.invoke(main, ["enrich", "--framework", "mock", "--json"])
407
408
assert result.exit_code == 0
409
data = json.loads(result.output)
410
assert "mock" in data
411
assert data["mock"]["promoted"] == 3
412
assert data["mock"]["edges_added"] == 2
413
assert isinstance(data["mock"]["patterns_found"], dict)
414

Keyboard Shortcuts

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