|
1
|
"""Tests for navegador CLI commands via click CliRunner.""" |
|
2
|
|
|
3
|
import json |
|
4
|
from pathlib import Path |
|
5
|
from unittest.mock import MagicMock, patch |
|
6
|
|
|
7
|
from click.testing import CliRunner |
|
8
|
|
|
9
|
from navegador.cli.commands import main |
|
10
|
from navegador.context.loader import ContextBundle, ContextNode |
|
11
|
|
|
12
|
# ── Helpers ─────────────────────────────────────────────────────────────────── |
|
13
|
|
|
14
|
def _mock_store(): |
|
15
|
store = MagicMock() |
|
16
|
store.query.return_value = MagicMock(result_set=[]) |
|
17
|
return store |
|
18
|
|
|
19
|
|
|
20
|
def _node(name="foo", type_="Function", file_path="app.py"): |
|
21
|
return ContextNode(name=name, type=type_, file_path=file_path) |
|
22
|
|
|
23
|
|
|
24
|
def _empty_bundle(name="target", type_="Function"): |
|
25
|
"""Return a ContextBundle with a minimal target for testing.""" |
|
26
|
return ContextBundle(target=_node(name, type_), nodes=[]) |
|
27
|
|
|
28
|
|
|
29
|
# ── init ────────────────────────────────────────────────────────────────────── |
|
30
|
|
|
31
|
class TestInitCommand: |
|
32
|
def test_creates_navegador_dir(self): |
|
33
|
runner = CliRunner() |
|
34
|
with runner.isolated_filesystem(): |
|
35
|
result = runner.invoke(main, ["init", "."]) |
|
36
|
assert result.exit_code == 0 |
|
37
|
assert Path(".navegador").exists() |
|
38
|
|
|
39
|
def test_shows_redis_hint_when_url_provided(self): |
|
40
|
runner = CliRunner() |
|
41
|
with runner.isolated_filesystem(): |
|
42
|
result = runner.invoke(main, ["init", ".", "--redis", "redis://localhost:6379"]) |
|
43
|
assert result.exit_code == 0 |
|
44
|
assert "redis://localhost:6379" in result.output |
|
45
|
|
|
46
|
def test_shows_sqlite_hint_by_default(self): |
|
47
|
runner = CliRunner() |
|
48
|
with runner.isolated_filesystem(): |
|
49
|
result = runner.invoke(main, ["init", "."]) |
|
50
|
assert result.exit_code == 0 |
|
51
|
assert "Local SQLite" in result.output |
|
52
|
|
|
53
|
def test_llm_provider_shown(self): |
|
54
|
runner = CliRunner() |
|
55
|
with runner.isolated_filesystem(): |
|
56
|
result = runner.invoke(main, ["init", ".", "--llm-provider", "anthropic"]) |
|
57
|
assert result.exit_code == 0 |
|
58
|
assert "anthropic" in result.output |
|
59
|
|
|
60
|
def test_cluster_flag_shown(self): |
|
61
|
runner = CliRunner() |
|
62
|
with runner.isolated_filesystem(): |
|
63
|
result = runner.invoke(main, ["init", ".", "--cluster"]) |
|
64
|
assert result.exit_code == 0 |
|
65
|
assert "Cluster mode" in result.output |
|
66
|
|
|
67
|
|
|
68
|
# ── ingest ──────────────────────────────────────────────────────────────────── |
|
69
|
|
|
70
|
class TestIngestCommand: |
|
71
|
def test_outputs_table_on_success(self): |
|
72
|
runner = CliRunner() |
|
73
|
with runner.isolated_filesystem(): |
|
74
|
Path("src").mkdir() |
|
75
|
with patch("navegador.cli.commands._get_store", return_value=_mock_store()), \ |
|
76
|
patch("navegador.ingestion.RepoIngester") as MockRI: |
|
77
|
MockRI.return_value.ingest.return_value = {"files": 5, "functions": 20} |
|
78
|
result = runner.invoke(main, ["ingest", "src"]) |
|
79
|
assert result.exit_code == 0 |
|
80
|
|
|
81
|
def test_json_flag_outputs_json(self): |
|
82
|
runner = CliRunner() |
|
83
|
with runner.isolated_filesystem(): |
|
84
|
Path("src").mkdir() |
|
85
|
with patch("navegador.cli.commands._get_store", return_value=_mock_store()), \ |
|
86
|
patch("navegador.ingestion.RepoIngester") as MockRI: |
|
87
|
MockRI.return_value.ingest.return_value = {"files": 5} |
|
88
|
result = runner.invoke(main, ["ingest", "src", "--json"]) |
|
89
|
assert result.exit_code == 0 |
|
90
|
data = json.loads(result.output) |
|
91
|
assert data["files"] == 5 |
|
92
|
|
|
93
|
def test_incremental_flag_passes_through(self): |
|
94
|
runner = CliRunner() |
|
95
|
with runner.isolated_filesystem(): |
|
96
|
Path("src").mkdir() |
|
97
|
with patch("navegador.cli.commands._get_store", return_value=_mock_store()), \ |
|
98
|
patch("navegador.ingestion.RepoIngester") as MockRI: |
|
99
|
MockRI.return_value.ingest.return_value = { |
|
100
|
"files": 2, "functions": 5, "classes": 1, "edges": 3, "skipped": 8 |
|
101
|
} |
|
102
|
result = runner.invoke(main, ["ingest", "src", "--incremental"]) |
|
103
|
assert result.exit_code == 0 |
|
104
|
MockRI.return_value.ingest.assert_called_once() |
|
105
|
_, kwargs = MockRI.return_value.ingest.call_args |
|
106
|
assert kwargs["incremental"] is True |
|
107
|
|
|
108
|
def test_watch_flag_calls_watch(self): |
|
109
|
runner = CliRunner() |
|
110
|
with runner.isolated_filesystem(): |
|
111
|
Path("src").mkdir() |
|
112
|
with patch("navegador.cli.commands._get_store", return_value=_mock_store()), \ |
|
113
|
patch("navegador.ingestion.RepoIngester") as MockRI: |
|
114
|
# watch should be called, simulate immediate stop |
|
115
|
MockRI.return_value.watch.side_effect = KeyboardInterrupt() |
|
116
|
result = runner.invoke(main, ["ingest", "src", "--watch", "--interval", "0.1"]) |
|
117
|
assert result.exit_code == 0 |
|
118
|
MockRI.return_value.watch.assert_called_once() |
|
119
|
|
|
120
|
def test_watch_with_interval(self): |
|
121
|
runner = CliRunner() |
|
122
|
with runner.isolated_filesystem(): |
|
123
|
Path("src").mkdir() |
|
124
|
with patch("navegador.cli.commands._get_store", return_value=_mock_store()), \ |
|
125
|
patch("navegador.ingestion.RepoIngester") as MockRI: |
|
126
|
MockRI.return_value.watch.side_effect = KeyboardInterrupt() |
|
127
|
runner.invoke(main, ["ingest", "src", "--watch", "--interval", "5.0"]) |
|
128
|
_, kwargs = MockRI.return_value.watch.call_args |
|
129
|
assert kwargs["interval"] == 5.0 |
|
130
|
|
|
131
|
|
|
132
|
# ── context ─────────────────────────────────────────────────────────────────── |
|
133
|
|
|
134
|
class TestContextCommand: |
|
135
|
def test_json_format(self): |
|
136
|
runner = CliRunner() |
|
137
|
bundle = ContextBundle(target=_node("MyClass", "Class"), nodes=[]) |
|
138
|
with patch("navegador.cli.commands._get_store", return_value=_mock_store()), \ |
|
139
|
patch("navegador.context.ContextLoader") as MockCL: |
|
140
|
MockCL.return_value.load_file.return_value = bundle |
|
141
|
result = runner.invoke(main, ["context", "app.py", "--format", "json"]) |
|
142
|
assert result.exit_code == 0 |
|
143
|
data = json.loads(result.output) |
|
144
|
assert isinstance(data, dict) |
|
145
|
|
|
146
|
def test_markdown_format(self): |
|
147
|
runner = CliRunner() |
|
148
|
bundle = ContextBundle(target=_node(), nodes=[]) |
|
149
|
with patch("navegador.cli.commands._get_store", return_value=_mock_store()), \ |
|
150
|
patch("navegador.context.ContextLoader") as MockCL: |
|
151
|
MockCL.return_value.load_file.return_value = bundle |
|
152
|
result = runner.invoke(main, ["context", "app.py"]) |
|
153
|
assert result.exit_code == 0 |
|
154
|
|
|
155
|
|
|
156
|
# ── function ────────────────────────────────────────────────────────────────── |
|
157
|
|
|
158
|
class TestFunctionCommand: |
|
159
|
def test_function_json(self): |
|
160
|
runner = CliRunner() |
|
161
|
bundle = ContextBundle(target=_node("my_func"), nodes=[]) |
|
162
|
with patch("navegador.cli.commands._get_store", return_value=_mock_store()), \ |
|
163
|
patch("navegador.context.ContextLoader") as MockCL: |
|
164
|
MockCL.return_value.load_function.return_value = bundle |
|
165
|
result = runner.invoke(main, ["function", "my_func", "--format", "json"]) |
|
166
|
assert result.exit_code == 0 |
|
167
|
json.loads(result.output) # must be valid JSON |
|
168
|
|
|
169
|
def test_function_with_file_option(self): |
|
170
|
runner = CliRunner() |
|
171
|
bundle = _empty_bundle() |
|
172
|
with patch("navegador.cli.commands._get_store", return_value=_mock_store()), \ |
|
173
|
patch("navegador.context.ContextLoader") as MockCL: |
|
174
|
MockCL.return_value.load_function.return_value = bundle |
|
175
|
result = runner.invoke(main, ["function", "foo", "--file", "bar.py"]) |
|
176
|
MockCL.return_value.load_function.assert_called_with( |
|
177
|
"foo", file_path="bar.py", depth=2) |
|
178
|
assert result.exit_code == 0 |
|
179
|
|
|
180
|
|
|
181
|
# ── class ───────────────────────────────────────────────────────────────────── |
|
182
|
|
|
183
|
class TestClassCommand: |
|
184
|
def test_class_json(self): |
|
185
|
runner = CliRunner() |
|
186
|
bundle = ContextBundle(target=_node("MyClass", "Class"), nodes=[]) |
|
187
|
with patch("navegador.cli.commands._get_store", return_value=_mock_store()), \ |
|
188
|
patch("navegador.context.ContextLoader") as MockCL: |
|
189
|
MockCL.return_value.load_class.return_value = bundle |
|
190
|
result = runner.invoke(main, ["class", "MyClass", "--format", "json"]) |
|
191
|
assert result.exit_code == 0 |
|
192
|
json.loads(result.output) |
|
193
|
|
|
194
|
|
|
195
|
# ── explain ─────────────────────────────────────────────────────────────────── |
|
196
|
|
|
197
|
class TestExplainCommand: |
|
198
|
def test_explain_json(self): |
|
199
|
runner = CliRunner() |
|
200
|
bundle = _empty_bundle() |
|
201
|
with patch("navegador.cli.commands._get_store", return_value=_mock_store()), \ |
|
202
|
patch("navegador.context.ContextLoader") as MockCL: |
|
203
|
MockCL.return_value.explain.return_value = bundle |
|
204
|
result = runner.invoke(main, ["explain", "SomeName", "--format", "json"]) |
|
205
|
assert result.exit_code == 0 |
|
206
|
json.loads(result.output) |
|
207
|
|
|
208
|
|
|
209
|
# ── search ──────────────────────────────────────────────────────────────────── |
|
210
|
|
|
211
|
class TestSearchCommand: |
|
212
|
def test_search_json_no_results(self): |
|
213
|
runner = CliRunner() |
|
214
|
with patch("navegador.cli.commands._get_store", return_value=_mock_store()), \ |
|
215
|
patch("navegador.context.ContextLoader") as MockCL: |
|
216
|
MockCL.return_value.search.return_value = [] |
|
217
|
result = runner.invoke(main, ["search", "foo", "--format", "json"]) |
|
218
|
assert result.exit_code == 0 |
|
219
|
assert json.loads(result.output) == [] |
|
220
|
|
|
221
|
def test_search_all_flag(self): |
|
222
|
runner = CliRunner() |
|
223
|
with patch("navegador.cli.commands._get_store", return_value=_mock_store()), \ |
|
224
|
patch("navegador.context.ContextLoader") as MockCL: |
|
225
|
MockCL.return_value.search_all.return_value = [] |
|
226
|
result = runner.invoke(main, ["search", "foo", "--all", "--format", "json"]) |
|
227
|
assert result.exit_code == 0 |
|
228
|
MockCL.return_value.search_all.assert_called_once() |
|
229
|
|
|
230
|
def test_search_docs_flag(self): |
|
231
|
runner = CliRunner() |
|
232
|
with patch("navegador.cli.commands._get_store", return_value=_mock_store()), \ |
|
233
|
patch("navegador.context.ContextLoader") as MockCL: |
|
234
|
MockCL.return_value.search_by_docstring.return_value = [] |
|
235
|
result = runner.invoke(main, ["search", "foo", "--docs", "--format", "json"]) |
|
236
|
assert result.exit_code == 0 |
|
237
|
MockCL.return_value.search_by_docstring.assert_called_once() |
|
238
|
|
|
239
|
def test_search_markdown_no_results(self): |
|
240
|
runner = CliRunner() |
|
241
|
with patch("navegador.cli.commands._get_store", return_value=_mock_store()), \ |
|
242
|
patch("navegador.context.ContextLoader") as MockCL: |
|
243
|
MockCL.return_value.search.return_value = [] |
|
244
|
result = runner.invoke(main, ["search", "nothing"]) |
|
245
|
assert result.exit_code == 0 |
|
246
|
|
|
247
|
def test_search_with_results(self): |
|
248
|
runner = CliRunner() |
|
249
|
node = _node("result_fn") |
|
250
|
with patch("navegador.cli.commands._get_store", return_value=_mock_store()), \ |
|
251
|
patch("navegador.context.ContextLoader") as MockCL: |
|
252
|
MockCL.return_value.search.return_value = [node] |
|
253
|
result = runner.invoke(main, ["search", "result", "--format", "json"]) |
|
254
|
assert result.exit_code == 0 |
|
255
|
data = json.loads(result.output) |
|
256
|
assert len(data) == 1 |
|
257
|
assert data[0]["name"] == "result_fn" |
|
258
|
|
|
259
|
|
|
260
|
# ── decorated ───────────────────────────────────────────────────────────────── |
|
261
|
|
|
262
|
class TestDecoratedCommand: |
|
263
|
def test_decorated_json_no_results(self): |
|
264
|
runner = CliRunner() |
|
265
|
with patch("navegador.cli.commands._get_store", return_value=_mock_store()), \ |
|
266
|
patch("navegador.context.ContextLoader") as MockCL: |
|
267
|
MockCL.return_value.decorated_by.return_value = [] |
|
268
|
result = runner.invoke(main, ["decorated", "login_required", "--format", "json"]) |
|
269
|
assert result.exit_code == 0 |
|
270
|
assert json.loads(result.output) == [] |
|
271
|
|
|
272
|
def test_decorated_with_results(self): |
|
273
|
runner = CliRunner() |
|
274
|
node = _node("my_view", "Function", "views.py") |
|
275
|
with patch("navegador.cli.commands._get_store", return_value=_mock_store()), \ |
|
276
|
patch("navegador.context.ContextLoader") as MockCL: |
|
277
|
MockCL.return_value.decorated_by.return_value = [node] |
|
278
|
result = runner.invoke(main, ["decorated", "login_required", "--format", "json"]) |
|
279
|
data = json.loads(result.output) |
|
280
|
assert data[0]["name"] == "my_view" |
|
281
|
|
|
282
|
|
|
283
|
# ── query ───────────────────────────────────────────────────────────────────── |
|
284
|
|
|
285
|
class TestQueryCommand: |
|
286
|
def test_returns_json(self): |
|
287
|
runner = CliRunner() |
|
288
|
store = _mock_store() |
|
289
|
store.query.return_value = MagicMock(result_set=[["Node1", "Node2"]]) |
|
290
|
with patch("navegador.cli.commands._get_store", return_value=store): |
|
291
|
result = runner.invoke(main, ["query", "MATCH (n) RETURN n.name"]) |
|
292
|
assert result.exit_code == 0 |
|
293
|
data = json.loads(result.output) |
|
294
|
assert data == [["Node1", "Node2"]] |
|
295
|
|
|
296
|
def test_empty_result(self): |
|
297
|
runner = CliRunner() |
|
298
|
store = _mock_store() |
|
299
|
with patch("navegador.cli.commands._get_store", return_value=store): |
|
300
|
result = runner.invoke(main, ["query", "MATCH (n) RETURN n"]) |
|
301
|
assert result.exit_code == 0 |
|
302
|
assert json.loads(result.output) == [] |
|
303
|
|
|
304
|
|
|
305
|
# ── add concept / rule / decision / person / domain ─────────────────────────── |
|
306
|
|
|
307
|
class TestAddCommands: |
|
308
|
def _run_add(self, *args): |
|
309
|
runner = CliRunner() |
|
310
|
with patch("navegador.cli.commands._get_store", return_value=_mock_store()), \ |
|
311
|
patch("navegador.ingestion.KnowledgeIngester") as MockKI: |
|
312
|
MockKI.return_value = MagicMock() |
|
313
|
result = runner.invoke(main, list(args)) |
|
314
|
return result, MockKI |
|
315
|
|
|
316
|
def test_add_concept(self): |
|
317
|
result, MockKI = self._run_add("add", "concept", "Payment", "--desc", "Handles money") |
|
318
|
assert result.exit_code == 0 |
|
319
|
MockKI.return_value.add_concept.assert_called_once() |
|
320
|
|
|
321
|
def test_add_rule(self): |
|
322
|
result, MockKI = self._run_add("add", "rule", "NoNullIds", "--severity", "critical") |
|
323
|
assert result.exit_code == 0 |
|
324
|
MockKI.return_value.add_rule.assert_called_once() |
|
325
|
|
|
326
|
def test_add_decision(self): |
|
327
|
result, MockKI = self._run_add("add", "decision", "Use PostgreSQL") |
|
328
|
assert result.exit_code == 0 |
|
329
|
MockKI.return_value.add_decision.assert_called_once() |
|
330
|
|
|
331
|
def test_add_person(self): |
|
332
|
result, MockKI = self._run_add("add", "person", "Alice", "--email", "[email protected]") |
|
333
|
assert result.exit_code == 0 |
|
334
|
MockKI.return_value.add_person.assert_called_once() |
|
335
|
|
|
336
|
def test_add_domain(self): |
|
337
|
result, MockKI = self._run_add("add", "domain", "Billing", "--desc", "All billing logic") |
|
338
|
assert result.exit_code == 0 |
|
339
|
MockKI.return_value.add_domain.assert_called_once() |
|
340
|
|
|
341
|
|
|
342
|
# ── annotate ────────────────────────────────────────────────────────────────── |
|
343
|
|
|
344
|
class TestAnnotateCommand: |
|
345
|
def test_annotate_with_concept(self): |
|
346
|
runner = CliRunner() |
|
347
|
with patch("navegador.cli.commands._get_store", return_value=_mock_store()), \ |
|
348
|
patch("navegador.ingestion.KnowledgeIngester") as MockKI: |
|
349
|
MockKI.return_value = MagicMock() |
|
350
|
result = runner.invoke(main, ["annotate", "process_payment", "--concept", "Payment"]) |
|
351
|
assert result.exit_code == 0 |
|
352
|
MockKI.return_value.annotate_code.assert_called_once() |
|
353
|
|
|
354
|
def test_annotate_with_rule(self): |
|
355
|
runner = CliRunner() |
|
356
|
with patch("navegador.cli.commands._get_store", return_value=_mock_store()), \ |
|
357
|
patch("navegador.ingestion.KnowledgeIngester") as MockKI: |
|
358
|
MockKI.return_value = MagicMock() |
|
359
|
result = runner.invoke(main, ["annotate", "validate_card", "--rule", "PCI"]) |
|
360
|
assert result.exit_code == 0 |
|
361
|
|
|
362
|
|
|
363
|
# ── domain / concept ────────────────────────────────────────────────────────── |
|
364
|
|
|
365
|
class TestDomainConceptCommands: |
|
366
|
def test_domain_json(self): |
|
367
|
runner = CliRunner() |
|
368
|
bundle = _empty_bundle() |
|
369
|
with patch("navegador.cli.commands._get_store", return_value=_mock_store()), \ |
|
370
|
patch("navegador.context.ContextLoader") as MockCL: |
|
371
|
MockCL.return_value.load_domain.return_value = bundle |
|
372
|
result = runner.invoke(main, ["domain", "Billing", "--format", "json"]) |
|
373
|
assert result.exit_code == 0 |
|
374
|
json.loads(result.output) |
|
375
|
|
|
376
|
def test_concept_json(self): |
|
377
|
runner = CliRunner() |
|
378
|
bundle = _empty_bundle() |
|
379
|
with patch("navegador.cli.commands._get_store", return_value=_mock_store()), \ |
|
380
|
patch("navegador.context.ContextLoader") as MockCL: |
|
381
|
MockCL.return_value.load_concept.return_value = bundle |
|
382
|
result = runner.invoke(main, ["concept", "Payment", "--format", "json"]) |
|
383
|
assert result.exit_code == 0 |
|
384
|
json.loads(result.output) |
|
385
|
|
|
386
|
|
|
387
|
# ── wiki ingest ─────────────────────────────────────────────────────────────── |
|
388
|
|
|
389
|
class TestWikiIngestCommand: |
|
390
|
def test_ingest_local_dir(self): |
|
391
|
runner = CliRunner() |
|
392
|
with runner.isolated_filesystem(): |
|
393
|
Path("wiki").mkdir() |
|
394
|
(Path("wiki") / "home.md").write_text("# Home") |
|
395
|
with patch("navegador.cli.commands._get_store", return_value=_mock_store()), \ |
|
396
|
patch("navegador.ingestion.WikiIngester") as MockWI: |
|
397
|
MockWI.return_value.ingest_local.return_value = {"pages": 1, "links": 0} |
|
398
|
result = runner.invoke(main, ["wiki", "ingest", "--dir", "wiki"]) |
|
399
|
assert result.exit_code == 0 |
|
400
|
assert "1" in result.output |
|
401
|
|
|
402
|
def test_error_without_repo_or_dir(self): |
|
403
|
runner = CliRunner() |
|
404
|
with patch("navegador.cli.commands._get_store", return_value=_mock_store()): |
|
405
|
result = runner.invoke(main, ["wiki", "ingest"]) |
|
406
|
assert result.exit_code != 0 |
|
407
|
|
|
408
|
def test_ingest_github_api(self): |
|
409
|
runner = CliRunner() |
|
410
|
with patch("navegador.cli.commands._get_store", return_value=_mock_store()), \ |
|
411
|
patch("navegador.ingestion.WikiIngester") as MockWI: |
|
412
|
MockWI.return_value.ingest_github_api.return_value = {"pages": 3, "links": 2} |
|
413
|
result = runner.invoke(main, ["wiki", "ingest", "--repo", "owner/repo", "--api"]) |
|
414
|
assert result.exit_code == 0 |
|
415
|
|
|
416
|
|
|
417
|
# ── stats ───────────────────────────────────────────────────────────────────── |
|
418
|
|
|
419
|
class TestStatsCommand: |
|
420
|
def test_json_output(self): |
|
421
|
runner = CliRunner() |
|
422
|
store = _mock_store() |
|
423
|
store.query.side_effect = [ |
|
424
|
MagicMock(result_set=[["Function", 10], ["Class", 5]]), |
|
425
|
MagicMock(result_set=[["CALLS", 20]]), |
|
426
|
] |
|
427
|
with patch("navegador.cli.commands._get_store", return_value=store): |
|
428
|
result = runner.invoke(main, ["stats", "--json"]) |
|
429
|
assert result.exit_code == 0 |
|
430
|
data = json.loads(result.output) |
|
431
|
assert data["total_nodes"] == 15 |
|
432
|
assert data["total_edges"] == 20 |
|
433
|
|
|
434
|
def test_table_output(self): |
|
435
|
runner = CliRunner() |
|
436
|
store = _mock_store() |
|
437
|
store.query.side_effect = [ |
|
438
|
MagicMock(result_set=[["Function", 10]]), |
|
439
|
MagicMock(result_set=[["CALLS", 5]]), |
|
440
|
] |
|
441
|
with patch("navegador.cli.commands._get_store", return_value=store): |
|
442
|
result = runner.invoke(main, ["stats"]) |
|
443
|
assert result.exit_code == 0 |
|
444
|
|
|
445
|
def test_empty_graph(self): |
|
446
|
runner = CliRunner() |
|
447
|
store = _mock_store() |
|
448
|
store.query.side_effect = [ |
|
449
|
MagicMock(result_set=[]), |
|
450
|
MagicMock(result_set=[]), |
|
451
|
] |
|
452
|
with patch("navegador.cli.commands._get_store", return_value=store): |
|
453
|
result = runner.invoke(main, ["stats", "--json"]) |
|
454
|
assert result.exit_code == 0 |
|
455
|
data = json.loads(result.output) |
|
456
|
assert data["total_nodes"] == 0 |
|
457
|
|
|
458
|
|
|
459
|
# ── planopticon ingest ──────────────────────────────────────────────────────── |
|
460
|
|
|
461
|
class TestPlanopticonIngestCommand: |
|
462
|
def test_auto_detect_kg(self): |
|
463
|
runner = CliRunner() |
|
464
|
with runner.isolated_filesystem(): |
|
465
|
Path("knowledge_graph.json").write_text('{"nodes":[],"relationships":[],"sources":[]}') |
|
466
|
with patch("navegador.cli.commands._get_store", return_value=_mock_store()), \ |
|
467
|
patch("navegador.ingestion.PlanopticonIngester") as MockPI: |
|
468
|
MockPI.return_value.ingest_kg.return_value = {"nodes": 0, "edges": 0} |
|
469
|
result = runner.invoke(main, ["planopticon", "ingest", "knowledge_graph.json"]) |
|
470
|
assert result.exit_code == 0 |
|
471
|
|
|
472
|
def test_auto_detect_manifest(self): |
|
473
|
runner = CliRunner() |
|
474
|
with runner.isolated_filesystem(): |
|
475
|
Path("manifest.json").write_text("{}") |
|
476
|
with patch("navegador.cli.commands._get_store", return_value=_mock_store()), \ |
|
477
|
patch("navegador.ingestion.PlanopticonIngester") as MockPI: |
|
478
|
MockPI.return_value.ingest_manifest.return_value = {"nodes": 0, "edges": 0} |
|
479
|
result = runner.invoke(main, ["planopticon", "ingest", "manifest.json"]) |
|
480
|
assert result.exit_code == 0 |
|
481
|
MockPI.return_value.ingest_manifest.assert_called_once() |
|
482
|
|
|
483
|
def test_json_output(self): |
|
484
|
runner = CliRunner() |
|
485
|
with runner.isolated_filesystem(): |
|
486
|
Path("kg.json").write_text('{"nodes":[],"relationships":[],"sources":[]}') |
|
487
|
with patch("navegador.cli.commands._get_store", return_value=_mock_store()), \ |
|
488
|
patch("navegador.ingestion.PlanopticonIngester") as MockPI: |
|
489
|
MockPI.return_value.ingest_kg.return_value = {"nodes": 3, "edges": 1} |
|
490
|
result = runner.invoke(main, ["planopticon", "ingest", "kg.json", "--json"]) |
|
491
|
assert result.exit_code == 0 |
|
492
|
data = json.loads(result.output) |
|
493
|
assert data["nodes"] == 3 |
|
494
|
|
|
495
|
def test_directory_resolves_manifest(self): |
|
496
|
runner = CliRunner() |
|
497
|
with runner.isolated_filesystem(): |
|
498
|
Path("output").mkdir() |
|
499
|
Path("output/manifest.json").write_text("{}") |
|
500
|
with patch("navegador.cli.commands._get_store", return_value=_mock_store()), \ |
|
501
|
patch("navegador.ingestion.PlanopticonIngester") as MockPI: |
|
502
|
MockPI.return_value.ingest_manifest.return_value = {"nodes": 0, "edges": 0} |
|
503
|
result = runner.invoke(main, ["planopticon", "ingest", "output"]) |
|
504
|
assert result.exit_code == 0 |
|
505
|
MockPI.return_value.ingest_manifest.assert_called_once() |
|
506
|
|
|
507
|
|
|
508
|
# ── --help smoke tests ───────────────────────────────────────────────────────── |
|
509
|
|
|
510
|
class TestHelp: |
|
511
|
def test_main_help(self): |
|
512
|
runner = CliRunner() |
|
513
|
result = runner.invoke(main, ["--help"]) |
|
514
|
assert result.exit_code == 0 |
|
515
|
assert "navegador" in result.output.lower() or "knowledge" in result.output.lower() |
|
516
|
|
|
517
|
def test_add_help(self): |
|
518
|
runner = CliRunner() |
|
519
|
result = runner.invoke(main, ["add", "--help"]) |
|
520
|
assert result.exit_code == 0 |
|
521
|
|
|
522
|
def test_wiki_help(self): |
|
523
|
runner = CliRunner() |
|
524
|
result = runner.invoke(main, ["wiki", "--help"]) |
|
525
|
assert result.exit_code == 0 |
|
526
|
|
|
527
|
def test_planopticon_help(self): |
|
528
|
runner = CliRunner() |
|
529
|
result = runner.invoke(main, ["planopticon", "--help"]) |
|
530
|
assert result.exit_code == 0 |
|
531
|
|
|
532
|
|
|
533
|
# ── _get_store with custom db path (lines 31-32) ────────────────────────────── |
|
534
|
|
|
535
|
class TestGetStoreCustomPath: |
|
536
|
def test_get_store_calls_get_store_with_custom_path(self): |
|
537
|
"""_get_store body: custom path is forwarded to config.get_store.""" |
|
538
|
from navegador.cli.commands import _get_store |
|
539
|
with patch("navegador.config.get_store", return_value=_mock_store()) as mock_gs: |
|
540
|
_get_store("/custom/path.db") |
|
541
|
mock_gs.assert_called_once_with("/custom/path.db") |
|
542
|
|
|
543
|
def test_get_store_passes_none_for_default_path(self): |
|
544
|
from navegador.cli.commands import _get_store |
|
545
|
from navegador.config import DEFAULT_DB_PATH |
|
546
|
with patch("navegador.config.get_store", return_value=_mock_store()) as mock_gs: |
|
547
|
_get_store(DEFAULT_DB_PATH) |
|
548
|
mock_gs.assert_called_once_with(None) |
|
549
|
|
|
550
|
|
|
551
|
# ── search table output with results (lines 208-216) ───────────────────────── |
|
552
|
|
|
553
|
class TestSearchTableOutput: |
|
554
|
def test_search_renders_table_with_results(self): |
|
555
|
runner = CliRunner() |
|
556
|
node = _node("process_payment", "Function", "payments.py") |
|
557
|
with patch("navegador.cli.commands._get_store", return_value=_mock_store()), \ |
|
558
|
patch("navegador.context.ContextLoader") as MockCL: |
|
559
|
MockCL.return_value.search.return_value = [node] |
|
560
|
result = runner.invoke(main, ["search", "payment"]) |
|
561
|
assert result.exit_code == 0 |
|
562
|
assert "process_payment" in result.output |
|
563
|
|
|
564
|
|
|
565
|
# ── decorated table output with results (lines 237-248) ────────────────────── |
|
566
|
|
|
567
|
class TestDecoratedTableOutput: |
|
568
|
def test_decorated_no_results_table(self): |
|
569
|
runner = CliRunner() |
|
570
|
with patch("navegador.cli.commands._get_store", return_value=_mock_store()), \ |
|
571
|
patch("navegador.context.ContextLoader") as MockCL: |
|
572
|
MockCL.return_value.decorated_by.return_value = [] |
|
573
|
result = runner.invoke(main, ["decorated", "login_required"]) |
|
574
|
assert result.exit_code == 0 |
|
575
|
assert "login_required" in result.output |
|
576
|
|
|
577
|
def test_decorated_renders_table_with_results(self): |
|
578
|
runner = CliRunner() |
|
579
|
node = _node("my_view", "Function", "views.py") |
|
580
|
with patch("navegador.cli.commands._get_store", return_value=_mock_store()), \ |
|
581
|
patch("navegador.context.ContextLoader") as MockCL: |
|
582
|
MockCL.return_value.decorated_by.return_value = [node] |
|
583
|
result = runner.invoke(main, ["decorated", "login_required"]) |
|
584
|
assert result.exit_code == 0 |
|
585
|
assert "my_view" in result.output |
|
586
|
|
|
587
|
|
|
588
|
# ── wiki ingest without --api flag (line 410) ───────────────────────────────── |
|
589
|
|
|
590
|
class TestWikiIngestGithubNoApi: |
|
591
|
def test_ingest_github_without_api_flag(self): |
|
592
|
runner = CliRunner() |
|
593
|
with patch("navegador.cli.commands._get_store", return_value=_mock_store()), \ |
|
594
|
patch("navegador.ingestion.WikiIngester") as MockWI: |
|
595
|
MockWI.return_value.ingest_github.return_value = {"pages": 5, "links": 3} |
|
596
|
result = runner.invoke(main, ["wiki", "ingest", "--repo", "owner/repo"]) |
|
597
|
assert result.exit_code == 0 |
|
598
|
MockWI.return_value.ingest_github.assert_called_once() |
|
599
|
assert "5" in result.output |
|
600
|
|
|
601
|
|
|
602
|
# ── planopticon dir with no recognised files (line 497) ────────────────────── |
|
603
|
|
|
604
|
class TestPlanopticonIngestNoKnownFiles: |
|
605
|
def test_empty_directory_raises_usage_error(self): |
|
606
|
runner = CliRunner() |
|
607
|
with runner.isolated_filesystem(): |
|
608
|
Path("output").mkdir() |
|
609
|
# No manifest.json, knowledge_graph.json, or interchange.json |
|
610
|
Path("output/readme.txt").write_text("nothing") |
|
611
|
with patch("navegador.cli.commands._get_store", return_value=_mock_store()): |
|
612
|
result = runner.invoke(main, ["planopticon", "ingest", "output"]) |
|
613
|
assert result.exit_code != 0 |
|
614
|
|
|
615
|
|
|
616
|
# ── planopticon auto-detect interchange/batch (lines 505, 507) ─────────────── |
|
617
|
|
|
618
|
class TestPlanopticonAutoDetect: |
|
619
|
def test_auto_detect_interchange(self): |
|
620
|
runner = CliRunner() |
|
621
|
with runner.isolated_filesystem(): |
|
622
|
Path("interchange.json").write_text("{}") |
|
623
|
with patch("navegador.cli.commands._get_store", return_value=_mock_store()), \ |
|
624
|
patch("navegador.ingestion.PlanopticonIngester") as MockPI: |
|
625
|
MockPI.return_value.ingest_interchange.return_value = {"nodes": 0, "edges": 0} |
|
626
|
result = runner.invoke(main, ["planopticon", "ingest", "interchange.json"]) |
|
627
|
assert result.exit_code == 0 |
|
628
|
MockPI.return_value.ingest_interchange.assert_called_once() |
|
629
|
|
|
630
|
def test_auto_detect_batch(self): |
|
631
|
runner = CliRunner() |
|
632
|
with runner.isolated_filesystem(): |
|
633
|
Path("batch.json").write_text("{}") |
|
634
|
with patch("navegador.cli.commands._get_store", return_value=_mock_store()), \ |
|
635
|
patch("navegador.ingestion.PlanopticonIngester") as MockPI: |
|
636
|
MockPI.return_value.ingest_batch.return_value = {"nodes": 0, "edges": 0} |
|
637
|
result = runner.invoke(main, ["planopticon", "ingest", "batch.json"]) |
|
638
|
assert result.exit_code == 0 |
|
639
|
MockPI.return_value.ingest_batch.assert_called_once() |
|
640
|
|
|
641
|
|
|
642
|
# ── mcp command (lines 538-549) ─────────────────────────────────────────────── |
|
643
|
|
|
644
|
# ── export / import ────────────────────────────────────────────────────────── |
|
645
|
|
|
646
|
class TestExportCommand: |
|
647
|
def test_export_success(self): |
|
648
|
runner = CliRunner() |
|
649
|
with runner.isolated_filesystem(): |
|
650
|
with patch("navegador.cli.commands._get_store", return_value=_mock_store()), \ |
|
651
|
patch("navegador.graph.export.export_graph", return_value={"nodes": 10, "edges": 5}): |
|
652
|
result = runner.invoke(main, ["export", "graph.jsonl"]) |
|
653
|
assert result.exit_code == 0 |
|
654
|
assert "10 nodes" in result.output |
|
655
|
|
|
656
|
def test_export_json(self): |
|
657
|
runner = CliRunner() |
|
658
|
with runner.isolated_filesystem(): |
|
659
|
with patch("navegador.cli.commands._get_store", return_value=_mock_store()), \ |
|
660
|
patch("navegador.graph.export.export_graph", return_value={"nodes": 10, "edges": 5}): |
|
661
|
result = runner.invoke(main, ["export", "graph.jsonl", "--json"]) |
|
662
|
assert result.exit_code == 0 |
|
663
|
data = json.loads(result.output) |
|
664
|
assert data["nodes"] == 10 |
|
665
|
|
|
666
|
|
|
667
|
class TestImportCommand: |
|
668
|
def test_import_success(self): |
|
669
|
runner = CliRunner() |
|
670
|
with runner.isolated_filesystem(): |
|
671
|
Path("graph.jsonl").write_text("") |
|
672
|
with patch("navegador.cli.commands._get_store", return_value=_mock_store()), \ |
|
673
|
patch("navegador.graph.export.import_graph", return_value={"nodes": 10, "edges": 5}): |
|
674
|
result = runner.invoke(main, ["import", "graph.jsonl"]) |
|
675
|
assert result.exit_code == 0 |
|
676
|
assert "10 nodes" in result.output |
|
677
|
|
|
678
|
def test_import_json(self): |
|
679
|
runner = CliRunner() |
|
680
|
with runner.isolated_filesystem(): |
|
681
|
Path("graph.jsonl").write_text("") |
|
682
|
with patch("navegador.cli.commands._get_store", return_value=_mock_store()), \ |
|
683
|
patch("navegador.graph.export.import_graph", return_value={"nodes": 8, "edges": 3}): |
|
684
|
result = runner.invoke(main, ["import", "graph.jsonl", "--json"]) |
|
685
|
assert result.exit_code == 0 |
|
686
|
data = json.loads(result.output) |
|
687
|
assert data["nodes"] == 8 |
|
688
|
|
|
689
|
def test_import_no_clear(self): |
|
690
|
runner = CliRunner() |
|
691
|
with runner.isolated_filesystem(): |
|
692
|
Path("graph.jsonl").write_text("") |
|
693
|
with patch("navegador.cli.commands._get_store", return_value=_mock_store()), \ |
|
694
|
patch("navegador.graph.export.import_graph", return_value={"nodes": 0, "edges": 0}) as mock_imp: |
|
695
|
runner.invoke(main, ["import", "graph.jsonl", "--no-clear"]) |
|
696
|
mock_imp.assert_called_once() |
|
697
|
assert mock_imp.call_args[1]["clear"] is False |
|
698
|
|
|
699
|
|
|
700
|
# ── migrate ────────────────────────────────────────────────────────────────── |
|
701
|
|
|
702
|
class TestMigrateCommand: |
|
703
|
def test_migrate_applies_migrations(self): |
|
704
|
runner = CliRunner() |
|
705
|
with patch("navegador.cli.commands._get_store", return_value=_mock_store()), \ |
|
706
|
patch("navegador.graph.migrations.get_schema_version", return_value=0), \ |
|
707
|
patch("navegador.graph.migrations.migrate", return_value=[1, 2]) as mock_migrate, \ |
|
708
|
patch("navegador.graph.migrations.CURRENT_SCHEMA_VERSION", 2): |
|
709
|
result = runner.invoke(main, ["migrate"]) |
|
710
|
assert result.exit_code == 0 |
|
711
|
assert "Migrated" in result.output |
|
712
|
|
|
713
|
def test_migrate_already_current(self): |
|
714
|
runner = CliRunner() |
|
715
|
with patch("navegador.cli.commands._get_store", return_value=_mock_store()), \ |
|
716
|
patch("navegador.graph.migrations.get_schema_version", return_value=2), \ |
|
717
|
patch("navegador.graph.migrations.migrate", return_value=[]): |
|
718
|
result = runner.invoke(main, ["migrate"]) |
|
719
|
assert result.exit_code == 0 |
|
720
|
assert "up to date" in result.output |
|
721
|
|
|
722
|
def test_migrate_check_needed(self): |
|
723
|
runner = CliRunner() |
|
724
|
with patch("navegador.cli.commands._get_store", return_value=_mock_store()), \ |
|
725
|
patch("navegador.graph.migrations.get_schema_version", return_value=0), \ |
|
726
|
patch("navegador.graph.migrations.needs_migration", return_value=True), \ |
|
727
|
patch("navegador.graph.migrations.CURRENT_SCHEMA_VERSION", 2): |
|
728
|
result = runner.invoke(main, ["migrate", "--check"]) |
|
729
|
assert result.exit_code == 0 |
|
730
|
assert "Migration needed" in result.output |
|
731
|
|
|
732
|
def test_migrate_check_not_needed(self): |
|
733
|
runner = CliRunner() |
|
734
|
with patch("navegador.cli.commands._get_store", return_value=_mock_store()), \ |
|
735
|
patch("navegador.graph.migrations.get_schema_version", return_value=2), \ |
|
736
|
patch("navegador.graph.migrations.needs_migration", return_value=False): |
|
737
|
result = runner.invoke(main, ["migrate", "--check"]) |
|
738
|
assert result.exit_code == 0 |
|
739
|
assert "up to date" in result.output |
|
740
|
|
|
741
|
|
|
742
|
class TestMcpCommand: |
|
743
|
def test_mcp_command_runs_server(self): |
|
744
|
from contextlib import asynccontextmanager |
|
745
|
|
|
746
|
runner = CliRunner() |
|
747
|
|
|
748
|
@asynccontextmanager |
|
749
|
async def _fake_stdio(): |
|
750
|
yield (MagicMock(), MagicMock()) |
|
751
|
|
|
752
|
async def _fake_run(*args, **kwargs): |
|
753
|
pass |
|
754
|
|
|
755
|
mock_server = MagicMock() |
|
756
|
mock_server.create_initialization_options.return_value = {} |
|
757
|
mock_server.run = _fake_run |
|
758
|
|
|
759
|
with patch("navegador.cli.commands._get_store", return_value=_mock_store()), \ |
|
760
|
patch.dict("sys.modules", { |
|
761
|
"mcp": MagicMock(), |
|
762
|
"mcp.server": MagicMock(), |
|
763
|
"mcp.server.stdio": MagicMock(stdio_server=_fake_stdio), |
|
764
|
}), \ |
|
765
|
patch("navegador.mcp.create_mcp_server", return_value=mock_server): |
|
766
|
result = runner.invoke(main, ["mcp"]) |
|
767
|
assert result.exit_code == 0 |
|
768
|
|