|
1
|
""" |
|
2
|
Tests for the navegador intelligence layer. |
|
3
|
|
|
4
|
Covers: |
|
5
|
- SemanticSearch._cosine_similarity |
|
6
|
- SemanticSearch.index / search (mock graph + mock LLM) |
|
7
|
- CommunityDetector.detect / store_communities (mock graph) |
|
8
|
- NLPEngine.natural_query / name_communities / generate_docs (mock LLM) |
|
9
|
- DocGenerator template mode and LLM mode (mock LLM) |
|
10
|
- CLI commands: semantic-search, communities, ask, generate-docs, docs |
|
11
|
|
|
12
|
All LLM providers are mocked — no real API calls are made. |
|
13
|
""" |
|
14
|
|
|
15
|
from __future__ import annotations |
|
16
|
|
|
17
|
import json |
|
18
|
import math |
|
19
|
from unittest.mock import MagicMock, patch |
|
20
|
|
|
21
|
import pytest |
|
22
|
from click.testing import CliRunner |
|
23
|
|
|
24
|
from navegador.cli.commands import main |
|
25
|
|
|
26
|
|
|
27
|
# ── Helpers ─────────────────────────────────────────────────────────────────── |
|
28
|
|
|
29
|
|
|
30
|
def _mock_store(result_rows=None): |
|
31
|
"""Return a MagicMock GraphStore whose .query() returns the given rows.""" |
|
32
|
store = MagicMock() |
|
33
|
result = MagicMock() |
|
34
|
result.result_set = result_rows if result_rows is not None else [] |
|
35
|
store.query.return_value = result |
|
36
|
return store |
|
37
|
|
|
38
|
|
|
39
|
def _mock_provider(complete_return="mocked answer", embed_return=None): |
|
40
|
"""Return a MagicMock LLMProvider.""" |
|
41
|
if embed_return is None: |
|
42
|
embed_return = [0.1, 0.2, 0.3, 0.4] |
|
43
|
provider = MagicMock() |
|
44
|
provider.complete.return_value = complete_return |
|
45
|
provider.embed.return_value = embed_return |
|
46
|
provider.name = "mock" |
|
47
|
provider.model = "mock-model" |
|
48
|
return provider |
|
49
|
|
|
50
|
|
|
51
|
# ── SemanticSearch: _cosine_similarity ──────────────────────────────────────── |
|
52
|
|
|
53
|
|
|
54
|
class TestCosineSimilarity: |
|
55
|
def setup_method(self): |
|
56
|
from navegador.intelligence.search import SemanticSearch |
|
57
|
|
|
58
|
self.cls = SemanticSearch |
|
59
|
|
|
60
|
def test_identical_vectors_return_one(self): |
|
61
|
v = [1.0, 0.0, 0.0] |
|
62
|
assert self.cls._cosine_similarity(v, v) == pytest.approx(1.0) |
|
63
|
|
|
64
|
def test_orthogonal_vectors_return_zero(self): |
|
65
|
a = [1.0, 0.0] |
|
66
|
b = [0.0, 1.0] |
|
67
|
assert self.cls._cosine_similarity(a, b) == pytest.approx(0.0) |
|
68
|
|
|
69
|
def test_opposite_vectors_return_minus_one(self): |
|
70
|
a = [1.0, 0.0] |
|
71
|
b = [-1.0, 0.0] |
|
72
|
assert self.cls._cosine_similarity(a, b) == pytest.approx(-1.0) |
|
73
|
|
|
74
|
def test_zero_vector_returns_zero(self): |
|
75
|
a = [0.0, 0.0] |
|
76
|
b = [1.0, 2.0] |
|
77
|
assert self.cls._cosine_similarity(a, b) == 0.0 |
|
78
|
|
|
79
|
def test_different_length_vectors_return_zero(self): |
|
80
|
a = [1.0, 2.0] |
|
81
|
b = [1.0, 2.0, 3.0] |
|
82
|
assert self.cls._cosine_similarity(a, b) == 0.0 |
|
83
|
|
|
84
|
def test_known_similarity(self): |
|
85
|
a = [1.0, 1.0] |
|
86
|
b = [1.0, 0.0] |
|
87
|
# cos(45°) = 1/sqrt(2) |
|
88
|
expected = 1.0 / math.sqrt(2) |
|
89
|
assert self.cls._cosine_similarity(a, b) == pytest.approx(expected, abs=1e-6) |
|
90
|
|
|
91
|
def test_general_non_unit_vectors(self): |
|
92
|
a = [3.0, 4.0] |
|
93
|
b = [3.0, 4.0] |
|
94
|
# Same direction → 1.0 regardless of magnitude |
|
95
|
assert self.cls._cosine_similarity(a, b) == pytest.approx(1.0) |
|
96
|
|
|
97
|
|
|
98
|
# ── SemanticSearch: index ───────────────────────────────────────────────────── |
|
99
|
|
|
100
|
|
|
101
|
class TestSemanticSearchIndex: |
|
102
|
def test_index_embeds_and_stores(self): |
|
103
|
from navegador.intelligence.search import SemanticSearch |
|
104
|
|
|
105
|
rows = [ |
|
106
|
["Function", "my_func", "app.py", "Does something important"], |
|
107
|
["Class", "MyClass", "app.py", "A useful class"], |
|
108
|
] |
|
109
|
store = _mock_store(rows) |
|
110
|
provider = _mock_provider(embed_return=[0.1, 0.2, 0.3]) |
|
111
|
|
|
112
|
ss = SemanticSearch(store, provider) |
|
113
|
count = ss.index(limit=10) |
|
114
|
|
|
115
|
assert count == 2 |
|
116
|
# embed called once per node |
|
117
|
assert provider.embed.call_count == 2 |
|
118
|
# SET query called for each node |
|
119
|
assert store.query.call_count >= 3 # 1 fetch + 2 set |
|
120
|
|
|
121
|
def test_index_skips_nodes_without_text(self): |
|
122
|
from navegador.intelligence.search import SemanticSearch |
|
123
|
|
|
124
|
rows = [ |
|
125
|
["Function", "no_doc", "app.py", ""], # empty text |
|
126
|
["Class", "HasDoc", "app.py", "Some docstring"], |
|
127
|
] |
|
128
|
store = _mock_store(rows) |
|
129
|
provider = _mock_provider(embed_return=[0.1, 0.2]) |
|
130
|
|
|
131
|
ss = SemanticSearch(store, provider) |
|
132
|
count = ss.index() |
|
133
|
|
|
134
|
assert count == 1 # only the node with text |
|
135
|
assert provider.embed.call_count == 1 |
|
136
|
|
|
137
|
def test_index_returns_zero_for_empty_graph(self): |
|
138
|
from navegador.intelligence.search import SemanticSearch |
|
139
|
|
|
140
|
store = _mock_store([]) |
|
141
|
provider = _mock_provider() |
|
142
|
ss = SemanticSearch(store, provider) |
|
143
|
assert ss.index() == 0 |
|
144
|
provider.embed.assert_not_called() |
|
145
|
|
|
146
|
|
|
147
|
# ── SemanticSearch: search ──────────────────────────────────────────────────── |
|
148
|
|
|
149
|
|
|
150
|
class TestSemanticSearchSearch: |
|
151
|
def test_search_returns_sorted_results(self): |
|
152
|
from navegador.intelligence.search import SemanticSearch |
|
153
|
|
|
154
|
# Two nodes with known embeddings |
|
155
|
# Node A: parallel to query → similarity 1.0 |
|
156
|
# Node B: orthogonal to query → similarity 0.0 |
|
157
|
query_vec = [1.0, 0.0] |
|
158
|
node_a_vec = [1.0, 0.0] # sim = 1.0 |
|
159
|
node_b_vec = [0.0, 1.0] # sim = 0.0 |
|
160
|
|
|
161
|
rows = [ |
|
162
|
["Function", "node_a", "a.py", "doc a", json.dumps(node_a_vec)], |
|
163
|
["Class", "node_b", "b.py", "doc b", json.dumps(node_b_vec)], |
|
164
|
] |
|
165
|
store = _mock_store(rows) |
|
166
|
provider = _mock_provider(embed_return=query_vec) |
|
167
|
|
|
168
|
ss = SemanticSearch(store, provider) |
|
169
|
results = ss.search("find something", limit=10) |
|
170
|
|
|
171
|
assert len(results) == 2 |
|
172
|
assert results[0]["name"] == "node_a" |
|
173
|
assert results[0]["score"] == pytest.approx(1.0) |
|
174
|
assert results[1]["name"] == "node_b" |
|
175
|
assert results[1]["score"] == pytest.approx(0.0) |
|
176
|
|
|
177
|
def test_search_respects_limit(self): |
|
178
|
from navegador.intelligence.search import SemanticSearch |
|
179
|
|
|
180
|
rows = [ |
|
181
|
["Function", f"func_{i}", "app.py", f"doc {i}", json.dumps([float(i), 0.0])] |
|
182
|
for i in range(1, 6) |
|
183
|
] |
|
184
|
store = _mock_store(rows) |
|
185
|
provider = _mock_provider(embed_return=[1.0, 0.0]) |
|
186
|
|
|
187
|
ss = SemanticSearch(store, provider) |
|
188
|
results = ss.search("query", limit=3) |
|
189
|
assert len(results) == 3 |
|
190
|
|
|
191
|
def test_search_handles_invalid_embedding_json(self): |
|
192
|
from navegador.intelligence.search import SemanticSearch |
|
193
|
|
|
194
|
rows = [ |
|
195
|
["Function", "bad_node", "app.py", "doc", "not-valid-json"], |
|
196
|
["Function", "good_node", "app.py", "doc", json.dumps([1.0, 0.0])], |
|
197
|
] |
|
198
|
store = _mock_store(rows) |
|
199
|
provider = _mock_provider(embed_return=[1.0, 0.0]) |
|
200
|
|
|
201
|
ss = SemanticSearch(store, provider) |
|
202
|
results = ss.search("q", limit=10) |
|
203
|
# Only good_node should appear |
|
204
|
assert len(results) == 1 |
|
205
|
assert results[0]["name"] == "good_node" |
|
206
|
|
|
207
|
def test_search_empty_graph_returns_empty_list(self): |
|
208
|
from navegador.intelligence.search import SemanticSearch |
|
209
|
|
|
210
|
store = _mock_store([]) |
|
211
|
provider = _mock_provider() |
|
212
|
ss = SemanticSearch(store, provider) |
|
213
|
assert ss.search("anything") == [] |
|
214
|
|
|
215
|
|
|
216
|
# ── CommunityDetector ───────────────────────────────────────────────────────── |
|
217
|
|
|
218
|
|
|
219
|
class TestCommunityDetector: |
|
220
|
"""Tests use a fully in-memory mock — no real FalkorDB required.""" |
|
221
|
|
|
222
|
def _make_store(self, node_rows, edge_rows): |
|
223
|
""" |
|
224
|
Return a MagicMock store that returns different rows for the first vs |
|
225
|
subsequent query calls (nodes query, edges query). |
|
226
|
""" |
|
227
|
store = MagicMock() |
|
228
|
|
|
229
|
node_result = MagicMock() |
|
230
|
node_result.result_set = node_rows |
|
231
|
edge_result = MagicMock() |
|
232
|
edge_result.result_set = edge_rows |
|
233
|
|
|
234
|
# First call → node query, second call → edge query, rest → set_community |
|
235
|
store.query.side_effect = [node_result, edge_result] + [ |
|
236
|
MagicMock(result_set=[]) for _ in range(100) |
|
237
|
] |
|
238
|
return store |
|
239
|
|
|
240
|
def test_two_cliques_form_separate_communities(self): |
|
241
|
from navegador.intelligence.community import CommunityDetector |
|
242
|
|
|
243
|
# Nodes: 0-1-2 form a triangle (clique), 3-4 form a pair |
|
244
|
# They have no edges between groups → two communities |
|
245
|
node_rows = [ |
|
246
|
[0, "func_a", "a.py", "Function"], |
|
247
|
[1, "func_b", "a.py", "Function"], |
|
248
|
[2, "func_c", "a.py", "Function"], |
|
249
|
[3, "func_d", "b.py", "Function"], |
|
250
|
[4, "func_e", "b.py", "Function"], |
|
251
|
] |
|
252
|
edge_rows = [ |
|
253
|
[0, 1], [1, 2], [0, 2], # triangle |
|
254
|
[3, 4], # pair |
|
255
|
] |
|
256
|
store = self._make_store(node_rows, edge_rows) |
|
257
|
detector = CommunityDetector(store) |
|
258
|
communities = detector.detect(min_size=2) |
|
259
|
|
|
260
|
assert len(communities) == 2 |
|
261
|
sizes = sorted(c.size for c in communities) |
|
262
|
assert sizes == [2, 3] |
|
263
|
|
|
264
|
def test_min_size_filters_small_communities(self): |
|
265
|
from navegador.intelligence.community import CommunityDetector |
|
266
|
|
|
267
|
node_rows = [ |
|
268
|
[0, "a", "x.py", "Function"], |
|
269
|
[1, "b", "x.py", "Function"], |
|
270
|
[2, "c", "x.py", "Function"], # isolated |
|
271
|
] |
|
272
|
edge_rows = [[0, 1]] |
|
273
|
store = self._make_store(node_rows, edge_rows) |
|
274
|
detector = CommunityDetector(store) |
|
275
|
|
|
276
|
communities = detector.detect(min_size=2) |
|
277
|
# Only the pair {a, b} passes; isolated node c gets size=1 (filtered) |
|
278
|
assert all(c.size >= 2 for c in communities) |
|
279
|
|
|
280
|
def test_empty_graph_returns_empty_list(self): |
|
281
|
from navegador.intelligence.community import CommunityDetector |
|
282
|
|
|
283
|
store = self._make_store([], []) |
|
284
|
detector = CommunityDetector(store) |
|
285
|
communities = detector.detect() |
|
286
|
assert communities == [] |
|
287
|
|
|
288
|
def test_community_density_is_one_for_complete_graph(self): |
|
289
|
from navegador.intelligence.community import CommunityDetector |
|
290
|
|
|
291
|
# 3-node complete graph |
|
292
|
node_rows = [ |
|
293
|
[0, "x", "", "Function"], |
|
294
|
[1, "y", "", "Function"], |
|
295
|
[2, "z", "", "Function"], |
|
296
|
] |
|
297
|
edge_rows = [[0, 1], [1, 2], [0, 2]] |
|
298
|
store = self._make_store(node_rows, edge_rows) |
|
299
|
detector = CommunityDetector(store) |
|
300
|
communities = detector.detect(min_size=3) |
|
301
|
|
|
302
|
assert len(communities) == 1 |
|
303
|
assert communities[0].density == pytest.approx(1.0) |
|
304
|
|
|
305
|
def test_community_members_are_strings(self): |
|
306
|
from navegador.intelligence.community import CommunityDetector |
|
307
|
|
|
308
|
node_rows = [ |
|
309
|
[0, "func_alpha", "f.py", "Function"], |
|
310
|
[1, "func_beta", "f.py", "Function"], |
|
311
|
] |
|
312
|
edge_rows = [[0, 1]] |
|
313
|
store = self._make_store(node_rows, edge_rows) |
|
314
|
detector = CommunityDetector(store) |
|
315
|
communities = detector.detect(min_size=2) |
|
316
|
|
|
317
|
members = communities[0].members |
|
318
|
assert all(isinstance(m, str) for m in members) |
|
319
|
assert set(members) == {"func_alpha", "func_beta"} |
|
320
|
|
|
321
|
def test_store_communities_calls_query_for_each_node(self): |
|
322
|
from navegador.intelligence.community import CommunityDetector |
|
323
|
|
|
324
|
node_rows = [ |
|
325
|
[10, "n1", "", "Function"], |
|
326
|
[11, "n2", "", "Function"], |
|
327
|
] |
|
328
|
edge_rows = [[10, 11]] |
|
329
|
store = self._make_store(node_rows, edge_rows) |
|
330
|
detector = CommunityDetector(store) |
|
331
|
detector.detect(min_size=2) |
|
332
|
|
|
333
|
# Reset side_effect so store_communities calls work cleanly |
|
334
|
store.query.side_effect = None |
|
335
|
store.query.return_value = MagicMock(result_set=[]) |
|
336
|
|
|
337
|
updated = detector.store_communities() |
|
338
|
assert updated == 2 # two nodes |
|
339
|
assert store.query.call_count >= 2 |
|
340
|
|
|
341
|
def test_community_sorted_largest_first(self): |
|
342
|
from navegador.intelligence.community import CommunityDetector |
|
343
|
|
|
344
|
# 4-node clique + 2-node pair with a bridge → label propagation may merge |
|
345
|
# Use two fully disconnected groups of sizes 4 and 2 |
|
346
|
node_rows = [ |
|
347
|
[0, "a", "", "F"], [1, "b", "", "F"], [2, "c", "", "F"], [3, "d", "", "F"], |
|
348
|
[4, "e", "", "F"], [5, "f", "", "F"], |
|
349
|
] |
|
350
|
edge_rows = [ |
|
351
|
[0, 1], [1, 2], [2, 3], [0, 3], # 4-cycle (all same community) |
|
352
|
[4, 5], # pair |
|
353
|
] |
|
354
|
store = self._make_store(node_rows, edge_rows) |
|
355
|
detector = CommunityDetector(store) |
|
356
|
communities = detector.detect(min_size=2) |
|
357
|
sizes = [c.size for c in communities] |
|
358
|
assert sizes == sorted(sizes, reverse=True) |
|
359
|
|
|
360
|
|
|
361
|
# ── NLPEngine ───────────────────────────────────────────────────────────────── |
|
362
|
|
|
363
|
|
|
364
|
class TestNLPEngine: |
|
365
|
def test_natural_query_calls_complete_twice(self): |
|
366
|
"""Should call complete once for Cypher generation, once for formatting.""" |
|
367
|
from navegador.intelligence.nlp import NLPEngine |
|
368
|
|
|
369
|
cypher_response = "MATCH (n:Function) RETURN n.name LIMIT 5" |
|
370
|
format_response = "There are 5 functions: ..." |
|
371
|
provider = MagicMock() |
|
372
|
provider.complete.side_effect = [cypher_response, format_response] |
|
373
|
|
|
374
|
store = _mock_store([["func_a"], ["func_b"]]) |
|
375
|
engine = NLPEngine(store, provider) |
|
376
|
|
|
377
|
result = engine.natural_query("List all functions") |
|
378
|
assert result == format_response |
|
379
|
assert provider.complete.call_count == 2 |
|
380
|
|
|
381
|
def test_natural_query_handles_query_error(self): |
|
382
|
"""When the generated Cypher fails, return an error message.""" |
|
383
|
from navegador.intelligence.nlp import NLPEngine |
|
384
|
|
|
385
|
provider = _mock_provider(complete_return="INVALID CYPHER !!!") |
|
386
|
store = MagicMock() |
|
387
|
store.query.side_effect = Exception("syntax error") |
|
388
|
|
|
389
|
engine = NLPEngine(store, provider) |
|
390
|
result = engine.natural_query("broken question") |
|
391
|
|
|
392
|
assert "Failed" in result or "Error" in result or "syntax error" in result |
|
393
|
|
|
394
|
def test_natural_query_strips_markdown_fences(self): |
|
395
|
"""LLM output with ```cypher fences should still execute.""" |
|
396
|
from navegador.intelligence.nlp import NLPEngine |
|
397
|
|
|
398
|
fenced_cypher = "```cypher\nMATCH (n) RETURN n.name LIMIT 1\n```" |
|
399
|
provider = MagicMock() |
|
400
|
provider.complete.side_effect = [fenced_cypher, "One node found."] |
|
401
|
|
|
402
|
store = _mock_store([["some_node"]]) |
|
403
|
engine = NLPEngine(store, provider) |
|
404
|
result = engine.natural_query("find a node") |
|
405
|
|
|
406
|
assert result == "One node found." |
|
407
|
# Verify the actual query executed was the clean Cypher (no fences) |
|
408
|
executed_cypher = store.query.call_args[0][0] |
|
409
|
assert "```" not in executed_cypher |
|
410
|
|
|
411
|
def test_name_communities_returns_one_entry_per_community(self): |
|
412
|
from navegador.intelligence.community import Community |
|
413
|
from navegador.intelligence.nlp import NLPEngine |
|
414
|
|
|
415
|
store = _mock_store() |
|
416
|
provider = _mock_provider(complete_return="Authentication Services") |
|
417
|
|
|
418
|
comms = [ |
|
419
|
Community(name="community_1", members=["login", "logout", "verify_token"], size=3), |
|
420
|
Community(name="community_2", members=["fetch_data", "store_record"], size=2), |
|
421
|
] |
|
422
|
engine = NLPEngine(store, provider) |
|
423
|
named = engine.name_communities(comms) |
|
424
|
|
|
425
|
assert len(named) == 2 |
|
426
|
assert all("suggested_name" in n for n in named) |
|
427
|
assert all("original_name" in n for n in named) |
|
428
|
assert provider.complete.call_count == 2 |
|
429
|
|
|
430
|
def test_name_communities_fallback_on_llm_error(self): |
|
431
|
"""If LLM raises, the original name is used.""" |
|
432
|
from navegador.intelligence.community import Community |
|
433
|
from navegador.intelligence.nlp import NLPEngine |
|
434
|
|
|
435
|
store = _mock_store() |
|
436
|
provider = MagicMock() |
|
437
|
provider.complete.side_effect = RuntimeError("API down") |
|
438
|
|
|
439
|
comm = Community(name="community_0", members=["a", "b"], size=2) |
|
440
|
engine = NLPEngine(store, provider) |
|
441
|
named = engine.name_communities([comm]) |
|
442
|
|
|
443
|
assert named[0]["suggested_name"] == "community_0" |
|
444
|
|
|
445
|
def test_generate_docs_returns_llm_string(self): |
|
446
|
from navegador.intelligence.nlp import NLPEngine |
|
447
|
|
|
448
|
expected_docs = "## my_func\nDoes great things." |
|
449
|
store = _mock_store([ |
|
450
|
["Function", "my_func", "app.py", "Does great things.", "def my_func():"] |
|
451
|
]) |
|
452
|
# Make subsequent query calls (callers, callees) also return empty |
|
453
|
store.query.side_effect = [ |
|
454
|
MagicMock(result_set=[["Function", "my_func", "app.py", "Does great things.", "def my_func():"]]), |
|
455
|
MagicMock(result_set=[]), |
|
456
|
MagicMock(result_set=[]), |
|
457
|
] |
|
458
|
provider = _mock_provider(complete_return=expected_docs) |
|
459
|
|
|
460
|
engine = NLPEngine(store, provider) |
|
461
|
result = engine.generate_docs("my_func", file_path="app.py") |
|
462
|
|
|
463
|
assert result == expected_docs |
|
464
|
provider.complete.assert_called_once() |
|
465
|
|
|
466
|
def test_generate_docs_works_when_node_not_found(self): |
|
467
|
"""When node doesn't exist, still calls LLM with empty context.""" |
|
468
|
from navegador.intelligence.nlp import NLPEngine |
|
469
|
|
|
470
|
store = MagicMock() |
|
471
|
store.query.return_value = MagicMock(result_set=[]) |
|
472
|
provider = _mock_provider(complete_return="No docs available.") |
|
473
|
|
|
474
|
engine = NLPEngine(store, provider) |
|
475
|
result = engine.generate_docs("nonexistent_func") |
|
476
|
|
|
477
|
assert "No docs available." in result |
|
478
|
|
|
479
|
|
|
480
|
# ── DocGenerator (template mode) ───────────────────────────────────────────── |
|
481
|
|
|
482
|
|
|
483
|
class TestDocGeneratorTemplateMode: |
|
484
|
def test_generate_file_docs_returns_markdown_with_symbols(self): |
|
485
|
from navegador.intelligence.docgen import DocGenerator |
|
486
|
|
|
487
|
rows = [ |
|
488
|
["Function", "greet", "Does greeting", "def greet():", 10], |
|
489
|
["Class", "Greeter", "A greeter class", "class Greeter:", 20], |
|
490
|
] |
|
491
|
store = _mock_store(rows) |
|
492
|
gen = DocGenerator(store, provider=None) |
|
493
|
|
|
494
|
docs = gen.generate_file_docs("app.py") |
|
495
|
|
|
496
|
assert "app.py" in docs |
|
497
|
assert "greet" in docs |
|
498
|
assert "Greeter" in docs |
|
499
|
assert "Does greeting" in docs |
|
500
|
|
|
501
|
def test_generate_file_docs_handles_empty_file(self): |
|
502
|
from navegador.intelligence.docgen import DocGenerator |
|
503
|
|
|
504
|
store = _mock_store([]) |
|
505
|
gen = DocGenerator(store, provider=None) |
|
506
|
|
|
507
|
docs = gen.generate_file_docs("empty.py") |
|
508
|
assert "No symbols" in docs |
|
509
|
|
|
510
|
def test_generate_module_docs_groups_by_file(self): |
|
511
|
from navegador.intelligence.docgen import DocGenerator |
|
512
|
|
|
513
|
rows = [ |
|
514
|
["Function", "func_a", "nav/graph/store.py", "Store a node", "def func_a():"], |
|
515
|
["Class", "GraphStore", "nav/graph/store.py", "Wraps the graph.", "class GraphStore:"], |
|
516
|
["Function", "func_b", "nav/graph/queries.py", "Query helper", "def func_b():"], |
|
517
|
] |
|
518
|
store = _mock_store(rows) |
|
519
|
gen = DocGenerator(store, provider=None) |
|
520
|
|
|
521
|
docs = gen.generate_module_docs("nav.graph") |
|
522
|
assert "nav/graph/store.py" in docs |
|
523
|
assert "nav/graph/queries.py" in docs |
|
524
|
assert "func_a" in docs |
|
525
|
assert "GraphStore" in docs |
|
526
|
|
|
527
|
def test_generate_module_docs_handles_no_results(self): |
|
528
|
from navegador.intelligence.docgen import DocGenerator |
|
529
|
|
|
530
|
store = _mock_store([]) |
|
531
|
gen = DocGenerator(store, provider=None) |
|
532
|
|
|
533
|
docs = gen.generate_module_docs("empty.module") |
|
534
|
assert "No symbols" in docs |
|
535
|
|
|
536
|
def test_generate_project_docs_includes_stats_and_files(self): |
|
537
|
from navegador.intelligence.docgen import DocGenerator |
|
538
|
|
|
539
|
store = MagicMock() |
|
540
|
|
|
541
|
stats_result = MagicMock() |
|
542
|
stats_result.result_set = [ |
|
543
|
["Function", 42], |
|
544
|
["Class", 10], |
|
545
|
] |
|
546
|
files_result = MagicMock() |
|
547
|
files_result.result_set = [ |
|
548
|
["navegador/graph/store.py"], |
|
549
|
["navegador/cli/commands.py"], |
|
550
|
] |
|
551
|
sym_result = MagicMock() |
|
552
|
sym_result.result_set = [ |
|
553
|
["Function", "my_func", "navegador/graph/store.py", "Does things"], |
|
554
|
] |
|
555
|
store.query.side_effect = [stats_result, files_result, sym_result] |
|
556
|
|
|
557
|
gen = DocGenerator(store, provider=None) |
|
558
|
docs = gen.generate_project_docs() |
|
559
|
|
|
560
|
assert "Project Documentation" in docs |
|
561
|
assert "Function" in docs |
|
562
|
assert "42" in docs |
|
563
|
assert "navegador/graph/store.py" in docs |
|
564
|
|
|
565
|
def test_signature_included_when_present(self): |
|
566
|
from navegador.intelligence.docgen import DocGenerator |
|
567
|
|
|
568
|
rows = [["Function", "my_func", "My doc", "def my_func(x: int) -> str:", 5]] |
|
569
|
store = _mock_store(rows) |
|
570
|
gen = DocGenerator(store, provider=None) |
|
571
|
|
|
572
|
docs = gen.generate_file_docs("f.py") |
|
573
|
assert "def my_func(x: int) -> str:" in docs |
|
574
|
|
|
575
|
|
|
576
|
# ── DocGenerator (LLM mode) ─────────────────────────────────────────────────── |
|
577
|
|
|
578
|
|
|
579
|
class TestDocGeneratorLLMMode: |
|
580
|
def test_generate_file_docs_uses_nlp_engine(self): |
|
581
|
from navegador.intelligence.docgen import DocGenerator |
|
582
|
|
|
583
|
rows = [["Function", "my_func", "Generated docs for my_func", "def my_func():", 1]] |
|
584
|
store = MagicMock() |
|
585
|
# 1st call: _FILE_SYMBOLS 2nd+: NLPEngine internal calls |
|
586
|
store.query.return_value = MagicMock(result_set=rows) |
|
587
|
|
|
588
|
provider = _mock_provider(complete_return="## my_func\nLLM-generated content.") |
|
589
|
gen = DocGenerator(store, provider=provider) |
|
590
|
docs = gen.generate_file_docs("app.py") |
|
591
|
|
|
592
|
assert "app.py" in docs |
|
593
|
provider.complete.assert_called() |
|
594
|
|
|
595
|
def test_generate_project_docs_uses_llm(self): |
|
596
|
from navegador.intelligence.docgen import DocGenerator |
|
597
|
|
|
598
|
store = MagicMock() |
|
599
|
# Return empty for template sub-calls |
|
600
|
store.query.return_value = MagicMock(result_set=[]) |
|
601
|
|
|
602
|
provider = _mock_provider(complete_return="# Project README\nLLM wrote this.") |
|
603
|
gen = DocGenerator(store, provider=provider) |
|
604
|
docs = gen.generate_project_docs() |
|
605
|
|
|
606
|
assert "Project README" in docs or "LLM wrote this" in docs |
|
607
|
provider.complete.assert_called_once() |
|
608
|
|
|
609
|
|
|
610
|
# ── CLI: semantic-search ────────────────────────────────────────────────────── |
|
611
|
|
|
612
|
|
|
613
|
class TestSemanticSearchCLI: |
|
614
|
def test_search_outputs_table(self): |
|
615
|
runner = CliRunner() |
|
616
|
with patch("navegador.cli.commands._get_store") as mock_store_fn, \ |
|
617
|
patch("navegador.llm.auto_provider") as mock_auto: |
|
618
|
store = _mock_store([]) |
|
619
|
mock_store_fn.return_value = store |
|
620
|
mock_provider = _mock_provider(embed_return=[1.0, 0.0]) |
|
621
|
mock_auto.return_value = mock_provider |
|
622
|
|
|
623
|
# search returns no results |
|
624
|
from navegador.intelligence.search import SemanticSearch |
|
625
|
with patch.object(SemanticSearch, "search", return_value=[]): |
|
626
|
result = runner.invoke(main, ["semantic-search", "test query"]) |
|
627
|
assert result.exit_code == 0 |
|
628
|
|
|
629
|
def test_search_with_index_flag(self): |
|
630
|
runner = CliRunner() |
|
631
|
with patch("navegador.cli.commands._get_store") as mock_store_fn, \ |
|
632
|
patch("navegador.llm.auto_provider") as mock_auto: |
|
633
|
store = _mock_store([]) |
|
634
|
mock_store_fn.return_value = store |
|
635
|
mock_provider = _mock_provider() |
|
636
|
mock_auto.return_value = mock_provider |
|
637
|
|
|
638
|
from navegador.intelligence.search import SemanticSearch |
|
639
|
with patch.object(SemanticSearch, "index", return_value=5) as mock_index, \ |
|
640
|
patch.object(SemanticSearch, "search", return_value=[]): |
|
641
|
result = runner.invoke(main, ["semantic-search", "test", "--index"]) |
|
642
|
assert result.exit_code == 0 |
|
643
|
mock_index.assert_called_once() |
|
644
|
|
|
645
|
def test_search_json_output(self): |
|
646
|
runner = CliRunner() |
|
647
|
fake_results = [ |
|
648
|
{"type": "Function", "name": "foo", "file_path": "a.py", "text": "doc", "score": 0.95} |
|
649
|
] |
|
650
|
with patch("navegador.cli.commands._get_store") as mock_store_fn, \ |
|
651
|
patch("navegador.llm.auto_provider") as mock_auto: |
|
652
|
store = _mock_store([]) |
|
653
|
mock_store_fn.return_value = store |
|
654
|
mock_auto.return_value = _mock_provider() |
|
655
|
|
|
656
|
from navegador.intelligence.search import SemanticSearch |
|
657
|
with patch.object(SemanticSearch, "search", return_value=fake_results): |
|
658
|
result = runner.invoke(main, ["semantic-search", "foo", "--json"]) |
|
659
|
assert result.exit_code == 0 |
|
660
|
data = json.loads(result.output) |
|
661
|
assert isinstance(data, list) |
|
662
|
assert data[0]["name"] == "foo" |
|
663
|
|
|
664
|
|
|
665
|
# ── CLI: communities ────────────────────────────────────────────────────────── |
|
666
|
|
|
667
|
|
|
668
|
class TestCommunitiesCLI: |
|
669
|
def _make_communities(self): |
|
670
|
from navegador.intelligence.community import Community |
|
671
|
|
|
672
|
return [ |
|
673
|
Community(name="community_0", members=["a", "b", "c"], size=3, density=1.0), |
|
674
|
Community(name="community_1", members=["x", "y"], size=2, density=1.0), |
|
675
|
] |
|
676
|
|
|
677
|
def test_communities_outputs_table(self): |
|
678
|
runner = CliRunner() |
|
679
|
with patch("navegador.cli.commands._get_store") as mock_store_fn: |
|
680
|
mock_store_fn.return_value = _mock_store() |
|
681
|
from navegador.intelligence.community import CommunityDetector |
|
682
|
with patch.object(CommunityDetector, "detect", return_value=self._make_communities()): |
|
683
|
result = runner.invoke(main, ["communities"]) |
|
684
|
assert result.exit_code == 0 |
|
685
|
|
|
686
|
def test_communities_json_output(self): |
|
687
|
runner = CliRunner() |
|
688
|
with patch("navegador.cli.commands._get_store") as mock_store_fn: |
|
689
|
mock_store_fn.return_value = _mock_store() |
|
690
|
from navegador.intelligence.community import CommunityDetector |
|
691
|
with patch.object(CommunityDetector, "detect", return_value=self._make_communities()): |
|
692
|
result = runner.invoke(main, ["communities", "--json"]) |
|
693
|
assert result.exit_code == 0 |
|
694
|
data = json.loads(result.output) |
|
695
|
assert len(data) == 2 |
|
696
|
assert data[0]["name"] == "community_0" |
|
697
|
|
|
698
|
def test_communities_min_size_passed(self): |
|
699
|
runner = CliRunner() |
|
700
|
with patch("navegador.cli.commands._get_store") as mock_store_fn: |
|
701
|
mock_store_fn.return_value = _mock_store() |
|
702
|
from navegador.intelligence.community import CommunityDetector |
|
703
|
with patch.object(CommunityDetector, "detect", return_value=[]) as mock_detect: |
|
704
|
runner.invoke(main, ["communities", "--min-size", "5"]) |
|
705
|
mock_detect.assert_called_once_with(min_size=5) |
|
706
|
|
|
707
|
def test_communities_empty_graph_message(self): |
|
708
|
runner = CliRunner() |
|
709
|
with patch("navegador.cli.commands._get_store") as mock_store_fn: |
|
710
|
mock_store_fn.return_value = _mock_store() |
|
711
|
from navegador.intelligence.community import CommunityDetector |
|
712
|
with patch.object(CommunityDetector, "detect", return_value=[]): |
|
713
|
result = runner.invoke(main, ["communities"]) |
|
714
|
assert result.exit_code == 0 |
|
715
|
assert "No communities" in result.output or result.exit_code == 0 |
|
716
|
|
|
717
|
def test_communities_store_labels_flag(self): |
|
718
|
runner = CliRunner() |
|
719
|
with patch("navegador.cli.commands._get_store") as mock_store_fn: |
|
720
|
mock_store_fn.return_value = _mock_store() |
|
721
|
from navegador.intelligence.community import CommunityDetector |
|
722
|
with patch.object(CommunityDetector, "detect", return_value=self._make_communities()), \ |
|
723
|
patch.object(CommunityDetector, "store_communities", return_value=5) as mock_store: |
|
724
|
result = runner.invoke(main, ["communities", "--store-labels"]) |
|
725
|
assert result.exit_code == 0 |
|
726
|
mock_store.assert_called_once() |
|
727
|
|
|
728
|
|
|
729
|
# ── CLI: ask ────────────────────────────────────────────────────────────────── |
|
730
|
|
|
731
|
|
|
732
|
class TestAskCLI: |
|
733
|
def test_ask_prints_answer(self): |
|
734
|
runner = CliRunner() |
|
735
|
with patch("navegador.cli.commands._get_store") as mock_store_fn, \ |
|
736
|
patch("navegador.llm.auto_provider") as mock_auto: |
|
737
|
mock_store_fn.return_value = _mock_store() |
|
738
|
mock_auto.return_value = _mock_provider() |
|
739
|
|
|
740
|
from navegador.intelligence.nlp import NLPEngine |
|
741
|
with patch.object(NLPEngine, "natural_query", return_value="The answer is 42."): |
|
742
|
result = runner.invoke(main, ["ask", "What is the answer?"]) |
|
743
|
assert result.exit_code == 0 |
|
744
|
assert "42" in result.output |
|
745
|
|
|
746
|
def test_ask_with_explicit_provider(self): |
|
747
|
runner = CliRunner() |
|
748
|
with patch("navegador.cli.commands._get_store") as mock_store_fn, \ |
|
749
|
patch("navegador.llm.get_provider") as mock_get: |
|
750
|
mock_store_fn.return_value = _mock_store() |
|
751
|
mock_get.return_value = _mock_provider() |
|
752
|
|
|
753
|
from navegador.intelligence.nlp import NLPEngine |
|
754
|
with patch.object(NLPEngine, "natural_query", return_value="Answer."): |
|
755
|
result = runner.invoke( |
|
756
|
main, ["ask", "question", "--provider", "openai"] |
|
757
|
) |
|
758
|
assert result.exit_code == 0 |
|
759
|
mock_get.assert_called_once_with("openai", model="") |
|
760
|
|
|
761
|
|
|
762
|
# ── CLI: generate-docs ──────────────────────────────────────────────────────── |
|
763
|
|
|
764
|
|
|
765
|
class TestGenerateDocsCLI: |
|
766
|
def test_generate_docs_prints_output(self): |
|
767
|
runner = CliRunner() |
|
768
|
with patch("navegador.cli.commands._get_store") as mock_store_fn, \ |
|
769
|
patch("navegador.llm.auto_provider") as mock_auto: |
|
770
|
mock_store_fn.return_value = _mock_store() |
|
771
|
mock_auto.return_value = _mock_provider() |
|
772
|
|
|
773
|
from navegador.intelligence.nlp import NLPEngine |
|
774
|
with patch.object(NLPEngine, "generate_docs", return_value="## my_func\nDocs here."): |
|
775
|
result = runner.invoke(main, ["generate-docs", "my_func"]) |
|
776
|
assert result.exit_code == 0 |
|
777
|
assert "my_func" in result.output or "Docs" in result.output |
|
778
|
|
|
779
|
def test_generate_docs_with_file_option(self): |
|
780
|
runner = CliRunner() |
|
781
|
with patch("navegador.cli.commands._get_store") as mock_store_fn, \ |
|
782
|
patch("navegador.llm.auto_provider") as mock_auto: |
|
783
|
mock_store_fn.return_value = _mock_store() |
|
784
|
mock_auto.return_value = _mock_provider() |
|
785
|
|
|
786
|
from navegador.intelligence.nlp import NLPEngine |
|
787
|
with patch.object(NLPEngine, "generate_docs", return_value="Docs.") as mock_gd: |
|
788
|
runner.invoke( |
|
789
|
main, ["generate-docs", "my_func", "--file", "app.py"] |
|
790
|
) |
|
791
|
mock_gd.assert_called_once_with("my_func", file_path="app.py") |
|
792
|
|
|
793
|
|
|
794
|
# ── CLI: docs ───────────────────────────────────────────────────────────────── |
|
795
|
|
|
796
|
|
|
797
|
class TestDocsCLI: |
|
798
|
def test_docs_file_path(self): |
|
799
|
runner = CliRunner() |
|
800
|
with patch("navegador.cli.commands._get_store") as mock_store_fn: |
|
801
|
mock_store_fn.return_value = _mock_store() |
|
802
|
from navegador.intelligence.docgen import DocGenerator |
|
803
|
with patch.object(DocGenerator, "generate_file_docs", return_value="# File docs") as mock_fd: |
|
804
|
result = runner.invoke(main, ["docs", "app/store.py"]) |
|
805
|
assert result.exit_code == 0 |
|
806
|
mock_fd.assert_called_once_with("app/store.py") |
|
807
|
|
|
808
|
def test_docs_module_name(self): |
|
809
|
runner = CliRunner() |
|
810
|
with patch("navegador.cli.commands._get_store") as mock_store_fn: |
|
811
|
mock_store_fn.return_value = _mock_store() |
|
812
|
from navegador.intelligence.docgen import DocGenerator |
|
813
|
with patch.object(DocGenerator, "generate_module_docs", return_value="# Module docs") as mock_md: |
|
814
|
result = runner.invoke(main, ["docs", "navegador.graph"]) |
|
815
|
assert result.exit_code == 0 |
|
816
|
mock_md.assert_called_once_with("navegador.graph") |
|
817
|
|
|
818
|
def test_docs_project_flag(self): |
|
819
|
runner = CliRunner() |
|
820
|
with patch("navegador.cli.commands._get_store") as mock_store_fn: |
|
821
|
mock_store_fn.return_value = _mock_store() |
|
822
|
from navegador.intelligence.docgen import DocGenerator |
|
823
|
with patch.object(DocGenerator, "generate_project_docs", return_value="# Project") as mock_pd: |
|
824
|
result = runner.invoke(main, ["docs", ".", "--project"]) |
|
825
|
assert result.exit_code == 0 |
|
826
|
mock_pd.assert_called_once() |
|
827
|
|
|
828
|
def test_docs_json_output(self): |
|
829
|
runner = CliRunner() |
|
830
|
with patch("navegador.cli.commands._get_store") as mock_store_fn: |
|
831
|
mock_store_fn.return_value = _mock_store() |
|
832
|
from navegador.intelligence.docgen import DocGenerator |
|
833
|
with patch.object(DocGenerator, "generate_project_docs", return_value="# Project"): |
|
834
|
result = runner.invoke(main, ["docs", ".", "--project", "--json"]) |
|
835
|
assert result.exit_code == 0 |
|
836
|
data = json.loads(result.output) |
|
837
|
assert "docs" in data |
|
838
|
|
|
839
|
def test_docs_with_llm_provider(self): |
|
840
|
runner = CliRunner() |
|
841
|
with patch("navegador.cli.commands._get_store") as mock_store_fn, \ |
|
842
|
patch("navegador.intelligence.docgen.DocGenerator.generate_file_docs", return_value="# Docs"): |
|
843
|
mock_store_fn.return_value = _mock_store() |
|
844
|
with patch("navegador.llm.get_provider") as mock_get: |
|
845
|
mock_get.return_value = _mock_provider() |
|
846
|
result = runner.invoke( |
|
847
|
main, ["docs", "app/store.py", "--provider", "openai"] |
|
848
|
) |
|
849
|
assert result.exit_code == 0 |
|
850
|
mock_get.assert_called_once_with("openai", model="") |
|
851
|
|