Navegador

feat: FrameworkEnricher base class and shared testing harness Abstract FrameworkEnricher with auto-detection, node promotion, and semantic edge helpers. CLI: navegador enrich [--framework name]. Auto-discovers enricher subclasses via pkgutil. Closes #23

lmata 2026-03-23 05:14 trunk
Commit 8c1f142eaa6ab94771273da31342cada2f62e100d2e6627cf49e762637f2d0f8
--- a/navegador/enrichment/__init__.py
+++ b/navegador/enrichment/__init__.py
@@ -0,0 +1,3 @@
1
+from navegador.enrichment.base import EnrichmentResult, FrameworkEnricher
2
+
3
+__all__ = ["EnrichmentResult", "FrameworkEnricher"]
--- a/navegador/enrichment/__init__.py
+++ b/navegador/enrichment/__init__.py
@@ -0,0 +1,3 @@
 
 
 
--- a/navegador/enrichment/__init__.py
+++ b/navegador/enrichment/__init__.py
@@ -0,0 +1,3 @@
1 from navegador.enrichment.base import EnrichmentResult, FrameworkEnricher
2
3 __all__ = ["EnrichmentResult", "FrameworkEnricher"]
--- a/navegador/enrichment/base.py
+++ b/navegador/enrichment/base.py
@@ -0,0 +1,43 @@
1
+"
2
+ "s.
3
+
4
+Post-processing step that runs after AST ingestion. Examines existing graph
5
+nodes and promotes generic Function/Class nodes to semantic framework types
6
+by adding labels/properties.
7
+"""
8
+
9
+from abc import ABC, abstractmethod
10
+
11
+from navegador.graph.store import GraphStore
12
+
13
+
14
+class EnrichmentResult:
15
+ """Result of an enrichment pass."""
16
+
17
+ def __ini"""
18
+ "ecific graph enrichment."""
19
+
20
+ def __init__(self, store: GraphStore):
21
+ self.store = store
22
+
23
+ @property
24
+ @abstractmethod
25
+ def framework_name(self) -> str:
26
+ """Name of the framework (e.g. 'django', 'fastapi')."""
27
+
28
+ @property
29
+ @abstractmethod
30
+ def detection_patterns(self) -> list[str]:
31
+ """Import module names that indicate this framework is in use.
32
+
33
+ Detection queries Import nodes for exact module matches, so use
34
+ the actual package name (e.g. 'django', 'fastapi', 'express').
35
+ """
36
+
37
+ @property
38
+ def detection_filesFile/import pattern]:
39
+ """Filenames E.g. ['manage.py', 'django.conf.settings'] for Django.
40
+ """
41
+ Detection queries Import nework imports
42
+ for pattern in self.detection_patterns:
43
+
--- a/navegador/enrichment/base.py
+++ b/navegador/enrichment/base.py
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/navegador/enrichment/base.py
+++ b/navegador/enrichment/base.py
@@ -0,0 +1,43 @@
1 "
2 "s.
3
4 Post-processing step that runs after AST ingestion. Examines existing graph
5 nodes and promotes generic Function/Class nodes to semantic framework types
6 by adding labels/properties.
7 """
8
9 from abc import ABC, abstractmethod
10
11 from navegador.graph.store import GraphStore
12
13
14 class EnrichmentResult:
15 """Result of an enrichment pass."""
16
17 def __ini"""
18 "ecific graph enrichment."""
19
20 def __init__(self, store: GraphStore):
21 self.store = store
22
23 @property
24 @abstractmethod
25 def framework_name(self) -> str:
26 """Name of the framework (e.g. 'django', 'fastapi')."""
27
28 @property
29 @abstractmethod
30 def detection_patterns(self) -> list[str]:
31 """Import module names that indicate this framework is in use.
32
33 Detection queries Import nodes for exact module matches, so use
34 the actual package name (e.g. 'django', 'fastapi', 'express').
35 """
36
37 @property
38 def detection_filesFile/import pattern]:
39 """Filenames E.g. ['manage.py', 'django.conf.settings'] for Django.
40 """
41 Detection queries Import nework imports
42 for pattern in self.detection_patterns:
43
--- a/tests/test_enrichment_base.py
+++ b/tests/test_enrichment_base.py
@@ -0,0 +1,376 @@
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
+ _, kwargs0 = calls[0]
133
+ t — E"""Tests for navegador.enrichment — EnrichmentResult, FrameworkEnricher, and CLI."""
134
+
135
+import json
136
+from unittest.mock import MagicMock, call, patch
137
+
138
+import pytest
139
+from click.testing import CliRunner
140
+
141
+frompattern": "mock_module"}
142
+ pattern": "mock_settings.py"}
143
+
144
+ def test_stops_early_when_first_pattern_matches(self):
145
+ store = _mock_store(result_set=[[5]])
146
+ enricher = MockEnricher(store)
147
+ assert enricher.detect() is True
148
+ # Should only query once (short-circuit on first match)
149
+ assert store._graph.query.call_count == 1
150
+
151
+
152
+# ── FrameworkEnricher._promote_node() ────────────────────────────────────────
153
+
154
+
155
+class TestPromoteNode:
156
+ def test_calls_store_query_with_correct_cypher_and_params(self):
157
+ store = _mock_store()
158
+ enricher = MockEnricher(store)
159
+ enricher._promote_node("MyView", "app/views.py", "DjangoView")
160
+
161
+ store._graph.query.assert_called_once()
162
+ cypher, params = store._graph.query.call_args[0]
163
+ assert "SET n.semantic_type = $semantic_type" in cypher
164
+ assert "n.name = $name" in cypher
165
+ assert "n.file_path = $file_path" in cypher
166
+ assert params["name"] == "MyView"
167
+ assert params["file_path"] == "app/views.py"
168
+ assert params["semantic_type"] == "DjangoView"
169
+
170
+ def test_extra_props_appended_to_set_clause(self):
171
+ store = _mock_store()
172
+ enricher = MockEnricher(store)
173
+ enricher._promote_node(
174
+ "MyModel", "app/models.py", "DjangoModel", props={"table": "my_table"}
175
+ )
176
+
177
+ cypher, params = store._graph.query.call_args[0]
178
+ assert "n.table = $table" in cypher
179
+ assert params["table"] == "my_table"
180
+
181
+ def test_no_extra_props_produces_clean_cypher(self):
182
+ store = _mock_store()
183
+ enricher = MockEnricher(store)
184
+ enricher._promote_node("Fn", "a.py", "endpoint")
185
+
186
+ cypher, _ = store._graph.query.call_args[0]
187
+ # Should not have a trailing comma or extra SET clause pieces
188
+ assert cypher.count("SET") == 1
189
+ assert "None" not in cypher
190
+
191
+ def test_multiple_extra_props(self):
192
+ store = _mock_store()
193
+ enricher = MockEnricher(store)
194
+ enricher._promote_node(
195
+ "Router", "routes.py", "FastAPIRouter", props={"prefix": "/api", "version": "v1"}
196
+ )
197
+
198
+ cypher, params = store._graph.query.call_args[0]
199
+ assert "n.prefix = $prefix" in cypher
200
+ assert "n.version = $version" in cypher
201
+ assert params["prefix"] == "/api"
202
+ assert params["version"] == "v1"
203
+
204
+
205
+# ── FrameworkEnricher._add_semantic_edge() ────────────────────────────────────
206
+
207
+
208
+class TestAddSemanticEdge:
209
+ def test_calls_store_query_with_correct_cypher_and_params(self):
210
+ store = _mock_store()
211
+ enricher = MockEnricher(store)
212
+ enricher._add_semantic_edge("UserView", "HANDLES", "UserModel")
213
+
214
+ store._graph.query.assert_called_once()
215
+ cypher, params = store._graph.query.call_args[0]
216
+ assert "MERGE (a)-[r:HANDLES]->(b)" in cypher
217
+ assert "a.name = $from_name" in cypher
218
+ assert "b.name = $to_name" in cypher
219
+ assert params["from_name"] == "UserView"
220
+ assert params["to_name"] == "UserModel"
221
+
222
+ def test_extra_props_produce_set_clause(self):
223
+ store = _mock_store()
224
+ enricher = MockEnricher(store)
225
+ enricher._add_semantic_edge(
226
+ "ViewA", "CALLS", "ViewB", props={"weight": 1, "layer": "http"}
227
+ )
228
+
229
+ cypher, params = store._graph.query.call_args[0]
230
+ assert "SET" in cypher
231
+ assert "r.weight = $weight" in cypher
232
+ assert "r.layer = $layer" in cypher
233
+ assert params["weight"] == 1
234
+ assert params["layer"] == "http"
235
+
236
+ def test_no_extra_props_no_set_clause(self):
237
+ store = _mock_store()
238
+ enricher = MockEnricher(store)
239
+ enricher._add_semantic_edge("A", "LINKS", "B")
240
+
241
+ cypher, _ = store._graph.query.call_args[0]
242
+ assert "SET" not in cypher
243
+
244
+ def test_edge_type_is_interpolated(self):
245
+ store = _mock_store()
246
+ enricher = MockEnricher(store)
247
+ enricher._add_semantic_edge("X", "CUSTOM_EDGE_TYPE", "Y")
248
+
249
+ cypher, _ = store._graph.query.call_args[0]
250
+ assert "CUSTOM_EDGE_TYPE" in cypher
251
+
252
+
253
+# ── MockEnricher.enrich() contract ────────────────────────────────────────────
254
+
255
+
256
+class TestMockEnricherEnrich:
257
+ def test_returns_enrichment_result(self):
258
+ store = _mock_store()
259
+ enricher = MockEnricher(store)
260
+ result = enricher.enrich()
261
+ assert isinstance(result, EnrichmentResult)
262
+
263
+ def test_result_values(self):
264
+ store = _mock_store()
265
+ enricher = MockEnricher(store)
266
+ result = enricher.enrich()
267
+ assert result.promoted == 3
268
+ assert result.edges_added == 2
269
+ assert result.patterns_found == {"mock_view": 3, "mock_model": 0}
270
+
271
+ def test_framework_name(self):
272
+ store = _mock_store()
273
+ assert MockEnricher(store).framework_name == "mock"
274
+
275
+ def test_detection_patterns(self):
276
+ store = _mock_store()
277
+ patterns = MockEnricher(store).detection_patterns
278
+ assert "mock_module" in patterns
279
+ assert "mock_settings.py" in patterns
280
+
281
+
282
+# ── Abstract enforcement ───────────────────────────────────────────────────────
283
+
284
+
285
+class TestAbstractEnforcement:
286
+ def test_cannot_instantiate_base_class_directly(self):
287
+ store = _mock_store()
288
+ with pytest.raises(TypeError):
289
+ FrameworkEnricher(store) # type: ignore[abstract]
290
+
291
+ def test_subclass_missing_framework_name_raises(self):
292
+ with pytest.raises(TypeError):
293
+
294
+ class Incomplete(FrameworkEnricher):
295
+ @property
296
+ def detection_patterns(self):
297
+ return []
298
+
299
+ def enrich(self):
300
+ return EnrichmentResult()
301
+
302
+ Incomplete(_mock_store())
303
+
304
+ def test_subclass_missing_detection_patterns_raises(self):
305
+ with pytest.raises(TypeError):
306
+
307
+ class Incomplete(FrameworkEnricher):
308
+ @property
309
+ def framework_name(self):
310
+ return "x"
311
+
312
+ def enrich(self):
313
+ return EnrichmentResult()
314
+
315
+ Incomplete(_mock_store())
316
+
317
+ def test_subclass_missing_enrich_raises(self):
318
+ with pytest.raises(TypeError):
319
+
320
+ class Incomplete(FrameworkEnricher):
321
+ @property
322
+ def framework_name(self):
323
+ return "x"
324
+
325
+ @property
326
+ def detection_patterns(self):
327
+ return []
328
+
329
+ Incomplete(_mock_store())
330
+
331
+
332
+# ── CLI: navegador enrich ──────────────────────────────────────────────────────
333
+
334
+
335
+class TestEnrichCLI:
336
+ def _runner(self):
337
+ return CliRunner()
338
+
339
+ def test_no_frameworks_detected_message(self):
340
+ from navegador.cli.commands import main
341
+
342
+ runner = self._runner()
343
+ with patch("navegador.cli.commands._get_store", return_value=_mock_store()):
344
+ # No enricher modules in package yet — message should appear
345
+ result = runner.invoke(main, ["enrich"])
346
+ assert result.exit_code == 0
347
+ assert "No frameworks detected" in result.output
348
+
349
+ def test_unknown_framework_exits_nonzero(self):
350
+ from navegador.cli.commands import main
351
+
352
+ runner = self._runner()
353
+ with patch("navegador.cli.commands._get_store", return_value=_mock_store()):
354
+ result = runner.invoke(main, ["enrich", "--framework", "nonexistent_xyz"])
355
+ assert result.exit_code != 0
356
+
357
+ def test_json_flag_produces_empty_object_when_no_frameworks(self):
358
+ from navegador.cli.commands import main
359
+
360
+ runner = self._runner()
361
+ with patch("navegador.cli.commands._get_store", return_value=_mock_store()):
362
+ result = runner.invoke(main, ["enrich", "--json"])
363
+ assert result.exit_code == 0
364
+ data = json.loads(result.output)
365
+ assert isinstance(data, dict)
366
+
367
+ def test_enrich_command_exists_in_main_group(self):
368
+ from navegador.cli.commands import main
369
+
370
+ assert "enrich" in main.commands
371
+
372
+ def test_enrich_runs_enricher_when_framework_registered(self):
373
+ """Patch the enrichment package discovery to inject MockEnricher."""
374
+ import pkgutil
375
+
376
+ from n
--- a/tests/test_enrichment_base.py
+++ b/tests/test_enrichment_base.py
@@ -0,0 +1,376 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/tests/test_enrichment_base.py
+++ b/tests/test_enrichment_base.py
@@ -0,0 +1,376 @@
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 _, kwargs0 = calls[0]
133 t — E"""Tests for navegador.enrichment — EnrichmentResult, FrameworkEnricher, and CLI."""
134
135 import json
136 from unittest.mock import MagicMock, call, patch
137
138 import pytest
139 from click.testing import CliRunner
140
141 frompattern": "mock_module"}
142 pattern": "mock_settings.py"}
143
144 def test_stops_early_when_first_pattern_matches(self):
145 store = _mock_store(result_set=[[5]])
146 enricher = MockEnricher(store)
147 assert enricher.detect() is True
148 # Should only query once (short-circuit on first match)
149 assert store._graph.query.call_count == 1
150
151
152 # ── FrameworkEnricher._promote_node() ────────────────────────────────────────
153
154
155 class TestPromoteNode:
156 def test_calls_store_query_with_correct_cypher_and_params(self):
157 store = _mock_store()
158 enricher = MockEnricher(store)
159 enricher._promote_node("MyView", "app/views.py", "DjangoView")
160
161 store._graph.query.assert_called_once()
162 cypher, params = store._graph.query.call_args[0]
163 assert "SET n.semantic_type = $semantic_type" in cypher
164 assert "n.name = $name" in cypher
165 assert "n.file_path = $file_path" in cypher
166 assert params["name"] == "MyView"
167 assert params["file_path"] == "app/views.py"
168 assert params["semantic_type"] == "DjangoView"
169
170 def test_extra_props_appended_to_set_clause(self):
171 store = _mock_store()
172 enricher = MockEnricher(store)
173 enricher._promote_node(
174 "MyModel", "app/models.py", "DjangoModel", props={"table": "my_table"}
175 )
176
177 cypher, params = store._graph.query.call_args[0]
178 assert "n.table = $table" in cypher
179 assert params["table"] == "my_table"
180
181 def test_no_extra_props_produces_clean_cypher(self):
182 store = _mock_store()
183 enricher = MockEnricher(store)
184 enricher._promote_node("Fn", "a.py", "endpoint")
185
186 cypher, _ = store._graph.query.call_args[0]
187 # Should not have a trailing comma or extra SET clause pieces
188 assert cypher.count("SET") == 1
189 assert "None" not in cypher
190
191 def test_multiple_extra_props(self):
192 store = _mock_store()
193 enricher = MockEnricher(store)
194 enricher._promote_node(
195 "Router", "routes.py", "FastAPIRouter", props={"prefix": "/api", "version": "v1"}
196 )
197
198 cypher, params = store._graph.query.call_args[0]
199 assert "n.prefix = $prefix" in cypher
200 assert "n.version = $version" in cypher
201 assert params["prefix"] == "/api"
202 assert params["version"] == "v1"
203
204
205 # ── FrameworkEnricher._add_semantic_edge() ────────────────────────────────────
206
207
208 class TestAddSemanticEdge:
209 def test_calls_store_query_with_correct_cypher_and_params(self):
210 store = _mock_store()
211 enricher = MockEnricher(store)
212 enricher._add_semantic_edge("UserView", "HANDLES", "UserModel")
213
214 store._graph.query.assert_called_once()
215 cypher, params = store._graph.query.call_args[0]
216 assert "MERGE (a)-[r:HANDLES]->(b)" in cypher
217 assert "a.name = $from_name" in cypher
218 assert "b.name = $to_name" in cypher
219 assert params["from_name"] == "UserView"
220 assert params["to_name"] == "UserModel"
221
222 def test_extra_props_produce_set_clause(self):
223 store = _mock_store()
224 enricher = MockEnricher(store)
225 enricher._add_semantic_edge(
226 "ViewA", "CALLS", "ViewB", props={"weight": 1, "layer": "http"}
227 )
228
229 cypher, params = store._graph.query.call_args[0]
230 assert "SET" in cypher
231 assert "r.weight = $weight" in cypher
232 assert "r.layer = $layer" in cypher
233 assert params["weight"] == 1
234 assert params["layer"] == "http"
235
236 def test_no_extra_props_no_set_clause(self):
237 store = _mock_store()
238 enricher = MockEnricher(store)
239 enricher._add_semantic_edge("A", "LINKS", "B")
240
241 cypher, _ = store._graph.query.call_args[0]
242 assert "SET" not in cypher
243
244 def test_edge_type_is_interpolated(self):
245 store = _mock_store()
246 enricher = MockEnricher(store)
247 enricher._add_semantic_edge("X", "CUSTOM_EDGE_TYPE", "Y")
248
249 cypher, _ = store._graph.query.call_args[0]
250 assert "CUSTOM_EDGE_TYPE" in cypher
251
252
253 # ── MockEnricher.enrich() contract ────────────────────────────────────────────
254
255
256 class TestMockEnricherEnrich:
257 def test_returns_enrichment_result(self):
258 store = _mock_store()
259 enricher = MockEnricher(store)
260 result = enricher.enrich()
261 assert isinstance(result, EnrichmentResult)
262
263 def test_result_values(self):
264 store = _mock_store()
265 enricher = MockEnricher(store)
266 result = enricher.enrich()
267 assert result.promoted == 3
268 assert result.edges_added == 2
269 assert result.patterns_found == {"mock_view": 3, "mock_model": 0}
270
271 def test_framework_name(self):
272 store = _mock_store()
273 assert MockEnricher(store).framework_name == "mock"
274
275 def test_detection_patterns(self):
276 store = _mock_store()
277 patterns = MockEnricher(store).detection_patterns
278 assert "mock_module" in patterns
279 assert "mock_settings.py" in patterns
280
281
282 # ── Abstract enforcement ───────────────────────────────────────────────────────
283
284
285 class TestAbstractEnforcement:
286 def test_cannot_instantiate_base_class_directly(self):
287 store = _mock_store()
288 with pytest.raises(TypeError):
289 FrameworkEnricher(store) # type: ignore[abstract]
290
291 def test_subclass_missing_framework_name_raises(self):
292 with pytest.raises(TypeError):
293
294 class Incomplete(FrameworkEnricher):
295 @property
296 def detection_patterns(self):
297 return []
298
299 def enrich(self):
300 return EnrichmentResult()
301
302 Incomplete(_mock_store())
303
304 def test_subclass_missing_detection_patterns_raises(self):
305 with pytest.raises(TypeError):
306
307 class Incomplete(FrameworkEnricher):
308 @property
309 def framework_name(self):
310 return "x"
311
312 def enrich(self):
313 return EnrichmentResult()
314
315 Incomplete(_mock_store())
316
317 def test_subclass_missing_enrich_raises(self):
318 with pytest.raises(TypeError):
319
320 class Incomplete(FrameworkEnricher):
321 @property
322 def framework_name(self):
323 return "x"
324
325 @property
326 def detection_patterns(self):
327 return []
328
329 Incomplete(_mock_store())
330
331
332 # ── CLI: navegador enrich ──────────────────────────────────────────────────────
333
334
335 class TestEnrichCLI:
336 def _runner(self):
337 return CliRunner()
338
339 def test_no_frameworks_detected_message(self):
340 from navegador.cli.commands import main
341
342 runner = self._runner()
343 with patch("navegador.cli.commands._get_store", return_value=_mock_store()):
344 # No enricher modules in package yet — message should appear
345 result = runner.invoke(main, ["enrich"])
346 assert result.exit_code == 0
347 assert "No frameworks detected" in result.output
348
349 def test_unknown_framework_exits_nonzero(self):
350 from navegador.cli.commands import main
351
352 runner = self._runner()
353 with patch("navegador.cli.commands._get_store", return_value=_mock_store()):
354 result = runner.invoke(main, ["enrich", "--framework", "nonexistent_xyz"])
355 assert result.exit_code != 0
356
357 def test_json_flag_produces_empty_object_when_no_frameworks(self):
358 from navegador.cli.commands import main
359
360 runner = self._runner()
361 with patch("navegador.cli.commands._get_store", return_value=_mock_store()):
362 result = runner.invoke(main, ["enrich", "--json"])
363 assert result.exit_code == 0
364 data = json.loads(result.output)
365 assert isinstance(data, dict)
366
367 def test_enrich_command_exists_in_main_group(self):
368 from navegador.cli.commands import main
369
370 assert "enrich" in main.commands
371
372 def test_enrich_runs_enricher_when_framework_registered(self):
373 """Patch the enrichment package discovery to inject MockEnricher."""
374 import pkgutil
375
376 from n

Keyboard Shortcuts

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