|
1
|
"""Tests for navegador.diff — DiffAnalyzer and the CLI 'diff' command.""" |
|
2
|
|
|
3
|
from __future__ import annotations |
|
4
|
|
|
5
|
import json |
|
6
|
import subprocess |
|
7
|
from pathlib import Path |
|
8
|
from unittest.mock import MagicMock, patch |
|
9
|
|
|
10
|
import pytest |
|
11
|
from click.testing import CliRunner |
|
12
|
|
|
13
|
from navegador.cli.commands import main |
|
14
|
from navegador.diff import ( |
|
15
|
DiffAnalyzer, |
|
16
|
_lines_overlap, |
|
17
|
_parse_unified_diff_hunks, |
|
18
|
) |
|
19
|
|
|
20
|
|
|
21
|
# ── Helpers ──────────────────────────────────────────────────────────────────── |
|
22
|
|
|
23
|
|
|
24
|
def _mock_store(result_set: list | None = None): |
|
25
|
"""Return a MagicMock GraphStore whose .query() yields *result_set*.""" |
|
26
|
store = MagicMock() |
|
27
|
store.query.return_value = MagicMock(result_set=result_set or []) |
|
28
|
return store |
|
29
|
|
|
30
|
|
|
31
|
def _analyzer(store=None, repo_path: Path | None = None, changed: list[str] | None = None): |
|
32
|
"""Build a DiffAnalyzer with the given store, patching GitAdapter.changed_files.""" |
|
33
|
if store is None: |
|
34
|
store = _mock_store() |
|
35
|
if repo_path is None: |
|
36
|
repo_path = Path("/fake/repo") |
|
37
|
analyzer = DiffAnalyzer(store, repo_path) |
|
38
|
if changed is not None: |
|
39
|
analyzer._git = MagicMock() |
|
40
|
analyzer._git.changed_files.return_value = changed |
|
41
|
return analyzer |
|
42
|
|
|
43
|
|
|
44
|
# ── _parse_unified_diff_hunks ───────────────────────────────────────────────── |
|
45
|
|
|
46
|
|
|
47
|
class TestParseUnifiedDiffHunks: |
|
48
|
SAMPLE_DIFF = """\ |
|
49
|
diff --git a/foo.py b/foo.py |
|
50
|
index 0000000..1111111 100644 |
|
51
|
--- a/foo.py |
|
52
|
+++ b/foo.py |
|
53
|
@@ -10,3 +10,5 @@ |
|
54
|
unchanged |
|
55
|
+added line 1 |
|
56
|
+added line 2 |
|
57
|
diff --git a/bar.py b/bar.py |
|
58
|
index 0000000..2222222 100644 |
|
59
|
--- a/bar.py |
|
60
|
+++ b/bar.py |
|
61
|
@@ -5 +5,2 @@ |
|
62
|
-old line |
|
63
|
+new line A |
|
64
|
+new line B |
|
65
|
""" |
|
66
|
|
|
67
|
def test_returns_dict(self): |
|
68
|
result = _parse_unified_diff_hunks(self.SAMPLE_DIFF) |
|
69
|
assert isinstance(result, dict) |
|
70
|
|
|
71
|
def test_detects_both_files(self): |
|
72
|
result = _parse_unified_diff_hunks(self.SAMPLE_DIFF) |
|
73
|
assert "foo.py" in result |
|
74
|
assert "bar.py" in result |
|
75
|
|
|
76
|
def test_correct_range_for_foo(self): |
|
77
|
result = _parse_unified_diff_hunks(self.SAMPLE_DIFF) |
|
78
|
# hunk: +10,5 → start=10, end=14 |
|
79
|
ranges = result["foo.py"] |
|
80
|
assert len(ranges) == 1 |
|
81
|
start, end = ranges[0] |
|
82
|
assert start == 10 |
|
83
|
assert end == 14 # 10 + 5 - 1 |
|
84
|
|
|
85
|
def test_correct_range_for_bar(self): |
|
86
|
result = _parse_unified_diff_hunks(self.SAMPLE_DIFF) |
|
87
|
# hunk: +5,2 → start=5, end=6 |
|
88
|
ranges = result["bar.py"] |
|
89
|
assert len(ranges) == 1 |
|
90
|
start, end = ranges[0] |
|
91
|
assert start == 5 |
|
92
|
assert end == 6 |
|
93
|
|
|
94
|
def test_empty_diff_returns_empty_dict(self): |
|
95
|
result = _parse_unified_diff_hunks("") |
|
96
|
assert result == {} |
|
97
|
|
|
98
|
def test_deleted_file_not_included(self): |
|
99
|
diff = """\ |
|
100
|
--- a/deleted.py |
|
101
|
+++ /dev/null |
|
102
|
@@ -1 +0,0 @@ |
|
103
|
-old |
|
104
|
""" |
|
105
|
result = _parse_unified_diff_hunks(diff) |
|
106
|
assert "deleted.py" not in result |
|
107
|
|
|
108
|
def test_multiple_hunks_same_file(self): |
|
109
|
diff = """\ |
|
110
|
diff --git a/multi.py b/multi.py |
|
111
|
--- a/multi.py |
|
112
|
+++ b/multi.py |
|
113
|
@@ -1,2 +1,3 @@ |
|
114
|
+first |
|
115
|
unchanged |
|
116
|
+second |
|
117
|
@@ -20 +21,2 @@ |
|
118
|
-old |
|
119
|
+new1 |
|
120
|
+new2 |
|
121
|
""" |
|
122
|
result = _parse_unified_diff_hunks(diff) |
|
123
|
assert "multi.py" in result |
|
124
|
assert len(result["multi.py"]) == 2 |
|
125
|
|
|
126
|
|
|
127
|
# ── _lines_overlap ───────────────────────────────────────────────────────────── |
|
128
|
|
|
129
|
|
|
130
|
class TestLinesOverlap: |
|
131
|
def test_exact_overlap(self): |
|
132
|
assert _lines_overlap([(10, 20)], 10, 20) is True |
|
133
|
|
|
134
|
def test_symbol_inside_range(self): |
|
135
|
assert _lines_overlap([(5, 30)], 10, 15) is True |
|
136
|
|
|
137
|
def test_range_inside_symbol(self): |
|
138
|
assert _lines_overlap([(12, 14)], 10, 20) is True |
|
139
|
|
|
140
|
def test_no_overlap_before(self): |
|
141
|
assert _lines_overlap([(20, 30)], 5, 10) is False |
|
142
|
|
|
143
|
def test_no_overlap_after(self): |
|
144
|
assert _lines_overlap([(1, 5)], 10, 20) is False |
|
145
|
|
|
146
|
def test_adjacent_not_overlapping(self): |
|
147
|
assert _lines_overlap([(1, 9)], 10, 20) is False |
|
148
|
|
|
149
|
def test_none_line_start_returns_false(self): |
|
150
|
assert _lines_overlap([(1, 100)], None, None) is False |
|
151
|
|
|
152
|
def test_no_line_end_uses_start(self): |
|
153
|
# line_end=None → treated as single-line symbol |
|
154
|
assert _lines_overlap([(10, 20)], 15, None) is True |
|
155
|
|
|
156
|
def test_empty_ranges_returns_false(self): |
|
157
|
assert _lines_overlap([], 10, 20) is False |
|
158
|
|
|
159
|
def test_multiple_ranges_one_hits(self): |
|
160
|
assert _lines_overlap([(1, 5), (50, 60)], 52, 55) is True |
|
161
|
|
|
162
|
|
|
163
|
# ── DiffAnalyzer.changed_files ──────────────────────────────────────────────── |
|
164
|
|
|
165
|
|
|
166
|
class TestDiffAnalyzerChangedFiles: |
|
167
|
def test_delegates_to_git_adapter(self): |
|
168
|
analyzer = _analyzer(changed=["a.py", "b.py"]) |
|
169
|
assert analyzer.changed_files() == ["a.py", "b.py"] |
|
170
|
|
|
171
|
def test_empty_when_no_changes(self): |
|
172
|
analyzer = _analyzer(changed=[]) |
|
173
|
assert analyzer.changed_files() == [] |
|
174
|
|
|
175
|
def test_returns_list(self): |
|
176
|
analyzer = _analyzer(changed=["x.py"]) |
|
177
|
assert isinstance(analyzer.changed_files(), list) |
|
178
|
|
|
179
|
def test_uses_subprocess_via_git_adapter(self, tmp_path): |
|
180
|
"""Verify changed_files() relies on subprocess (through GitAdapter._run).""" |
|
181
|
repo = tmp_path / "repo" |
|
182
|
repo.mkdir() |
|
183
|
store = _mock_store() |
|
184
|
analyzer = DiffAnalyzer(store, repo) |
|
185
|
|
|
186
|
fake_result = MagicMock() |
|
187
|
fake_result.stdout = "changed.py\n" |
|
188
|
fake_result.returncode = 0 |
|
189
|
|
|
190
|
with patch("subprocess.run", return_value=fake_result): |
|
191
|
files = analyzer.changed_files() |
|
192
|
|
|
193
|
assert "changed.py" in files |
|
194
|
|
|
195
|
|
|
196
|
# ── DiffAnalyzer.changed_lines ──────────────────────────────────────────────── |
|
197
|
|
|
198
|
|
|
199
|
class TestDiffAnalyzerChangedLines: |
|
200
|
def test_returns_dict(self, tmp_path): |
|
201
|
analyzer = _analyzer(changed=["f.py"]) |
|
202
|
fake = MagicMock() |
|
203
|
fake.returncode = 0 |
|
204
|
fake.stdout = "+++ b/f.py\n@@ -1 +1,3 @@\n+a\n+b\n+c\n" |
|
205
|
with patch("subprocess.run", return_value=fake): |
|
206
|
result = analyzer.changed_lines() |
|
207
|
assert isinstance(result, dict) |
|
208
|
|
|
209
|
def test_fallback_on_no_output(self): |
|
210
|
"""No diff output → full-file sentinel range for each changed file.""" |
|
211
|
analyzer = _analyzer(changed=["x.py", "y.py"]) |
|
212
|
fake = MagicMock() |
|
213
|
fake.returncode = 0 |
|
214
|
fake.stdout = "" |
|
215
|
with patch("subprocess.run", return_value=fake): |
|
216
|
result = analyzer.changed_lines() |
|
217
|
assert "x.py" in result |
|
218
|
assert "y.py" in result |
|
219
|
assert result["x.py"] == [(1, 999_999)] |
|
220
|
|
|
221
|
def test_fallback_on_nonzero_exit(self): |
|
222
|
"""Non-zero exit (e.g. no HEAD) → full-file sentinel for all changed files.""" |
|
223
|
analyzer = _analyzer(changed=["z.py"]) |
|
224
|
fake = MagicMock() |
|
225
|
fake.returncode = 128 |
|
226
|
fake.stdout = "" |
|
227
|
with patch("subprocess.run", return_value=fake): |
|
228
|
result = analyzer.changed_lines() |
|
229
|
assert result["z.py"] == [(1, 999_999)] |
|
230
|
|
|
231
|
def test_missing_files_get_sentinel(self): |
|
232
|
"""Files in changed_files() but absent from diff get sentinel range.""" |
|
233
|
analyzer = _analyzer(changed=["in_diff.py", "not_in_diff.py"]) |
|
234
|
fake = MagicMock() |
|
235
|
fake.returncode = 0 |
|
236
|
fake.stdout = "+++ b/in_diff.py\n@@ -5 +5,2 @@ \n+x\n+y\n" |
|
237
|
with patch("subprocess.run", return_value=fake): |
|
238
|
result = analyzer.changed_lines() |
|
239
|
assert "not_in_diff.py" in result |
|
240
|
assert result["not_in_diff.py"] == [(1, 999_999)] |
|
241
|
|
|
242
|
|
|
243
|
# ── DiffAnalyzer.affected_symbols ───────────────────────────────────────────── |
|
244
|
|
|
245
|
|
|
246
|
class TestDiffAnalyzerAffectedSymbols: |
|
247
|
def _sym_rows(self): |
|
248
|
"""Return fake graph rows: (type, name, file_path, line_start, line_end).""" |
|
249
|
return [ |
|
250
|
("Function", "do_thing", "app.py", 10, 25), |
|
251
|
("Class", "MyClass", "app.py", 30, 80), |
|
252
|
("Method", "helper", "utils.py", 5, 15), |
|
253
|
] |
|
254
|
|
|
255
|
def test_returns_list(self): |
|
256
|
store = _mock_store(result_set=self._sym_rows()) |
|
257
|
analyzer = _analyzer(store=store, changed=["app.py"]) |
|
258
|
with patch.object(analyzer, "changed_lines", return_value={"app.py": [(1, 999_999)]}): |
|
259
|
result = analyzer.affected_symbols() |
|
260
|
assert isinstance(result, list) |
|
261
|
|
|
262
|
def test_symbols_overlap_returned(self): |
|
263
|
store = _mock_store(result_set=self._sym_rows()) |
|
264
|
analyzer = _analyzer(store=store, changed=["app.py"]) |
|
265
|
# Changed lines 15-20 overlap do_thing (10-25) |
|
266
|
with patch.object(analyzer, "changed_lines", return_value={"app.py": [(15, 20)]}): |
|
267
|
result = analyzer.affected_symbols() |
|
268
|
names = [s["name"] for s in result] |
|
269
|
assert "do_thing" in names |
|
270
|
|
|
271
|
def test_non_overlapping_symbols_excluded(self): |
|
272
|
store = _mock_store(result_set=self._sym_rows()) |
|
273
|
analyzer = _analyzer(store=store, changed=["app.py"]) |
|
274
|
# Changed lines 50-60 overlap MyClass (30-80) but not do_thing (10-25) |
|
275
|
with patch.object(analyzer, "changed_lines", return_value={"app.py": [(50, 60)]}): |
|
276
|
result = analyzer.affected_symbols() |
|
277
|
names = [s["name"] for s in result] |
|
278
|
assert "MyClass" in names |
|
279
|
assert "do_thing" not in names |
|
280
|
|
|
281
|
def test_empty_when_no_graph_nodes(self): |
|
282
|
store = _mock_store(result_set=[]) |
|
283
|
analyzer = _analyzer(store=store, changed=["app.py"]) |
|
284
|
with patch.object(analyzer, "changed_lines", return_value={"app.py": [(1, 50)]}): |
|
285
|
result = analyzer.affected_symbols() |
|
286
|
assert result == [] |
|
287
|
|
|
288
|
def test_empty_when_no_changed_files(self): |
|
289
|
store = _mock_store(result_set=self._sym_rows()) |
|
290
|
analyzer = _analyzer(store=store, changed=[]) |
|
291
|
with patch.object(analyzer, "changed_lines", return_value={}): |
|
292
|
result = analyzer.affected_symbols() |
|
293
|
assert result == [] |
|
294
|
|
|
295
|
def test_symbol_dict_has_required_keys(self): |
|
296
|
store = _mock_store(result_set=[("Function", "foo", "a.py", 1, 10)]) |
|
297
|
analyzer = _analyzer(store=store, changed=["a.py"]) |
|
298
|
with patch.object(analyzer, "changed_lines", return_value={"a.py": [(1, 10)]}): |
|
299
|
result = analyzer.affected_symbols() |
|
300
|
assert len(result) == 1 |
|
301
|
sym = result[0] |
|
302
|
assert "type" in sym |
|
303
|
assert "name" in sym |
|
304
|
assert "file_path" in sym |
|
305
|
assert "line_start" in sym |
|
306
|
assert "line_end" in sym |
|
307
|
|
|
308
|
def test_no_duplicate_symbols(self): |
|
309
|
"""Same symbol matched by two hunk ranges must appear only once.""" |
|
310
|
rows = [("Function", "foo", "a.py", 5, 20)] |
|
311
|
store = _mock_store(result_set=rows) |
|
312
|
analyzer = _analyzer(store=store, changed=["a.py"]) |
|
313
|
with patch.object(analyzer, "changed_lines", return_value={"a.py": [(5, 10), (15, 20)]}): |
|
314
|
result = analyzer.affected_symbols() |
|
315
|
assert len(result) == 1 |
|
316
|
|
|
317
|
|
|
318
|
# ── DiffAnalyzer.affected_knowledge ─────────────────────────────────────────── |
|
319
|
|
|
320
|
|
|
321
|
class TestDiffAnalyzerAffectedKnowledge: |
|
322
|
def _k_rows(self): |
|
323
|
"""Fake knowledge rows: (type, name, description, domain, status).""" |
|
324
|
return [ |
|
325
|
("Concept", "Billing", "Handles money", "finance", "stable"), |
|
326
|
("Rule", "no_refund_after_30d", "30 day rule", "finance", "active"), |
|
327
|
] |
|
328
|
|
|
329
|
def test_returns_list(self): |
|
330
|
store = _mock_store(result_set=self._k_rows()) |
|
331
|
analyzer = _analyzer(store=store) |
|
332
|
sym = [{"name": "charge", "file_path": "billing.py"}] |
|
333
|
with patch.object(analyzer, "affected_symbols", return_value=sym): |
|
334
|
result = analyzer.affected_knowledge() |
|
335
|
assert isinstance(result, list) |
|
336
|
|
|
337
|
def test_knowledge_nodes_returned(self): |
|
338
|
store = _mock_store(result_set=self._k_rows()) |
|
339
|
analyzer = _analyzer(store=store) |
|
340
|
sym = [{"name": "charge", "file_path": "billing.py"}] |
|
341
|
with patch.object(analyzer, "affected_symbols", return_value=sym): |
|
342
|
result = analyzer.affected_knowledge() |
|
343
|
names = [k["name"] for k in result] |
|
344
|
assert "Billing" in names |
|
345
|
assert "no_refund_after_30d" in names |
|
346
|
|
|
347
|
def test_empty_when_no_symbols(self): |
|
348
|
store = _mock_store(result_set=self._k_rows()) |
|
349
|
analyzer = _analyzer(store=store) |
|
350
|
with patch.object(analyzer, "affected_symbols", return_value=[]): |
|
351
|
result = analyzer.affected_knowledge() |
|
352
|
assert result == [] |
|
353
|
|
|
354
|
def test_empty_when_no_graph_knowledge(self): |
|
355
|
store = _mock_store(result_set=[]) |
|
356
|
analyzer = _analyzer(store=store) |
|
357
|
sym = [{"name": "foo", "file_path": "a.py"}] |
|
358
|
with patch.object(analyzer, "affected_symbols", return_value=sym): |
|
359
|
result = analyzer.affected_knowledge() |
|
360
|
assert result == [] |
|
361
|
|
|
362
|
def test_no_duplicate_knowledge_nodes(self): |
|
363
|
"""Two symbols linking to the same knowledge node → deduplicated.""" |
|
364
|
rows = [("Concept", "SharedConcept", "desc", "core", "stable")] |
|
365
|
store = _mock_store(result_set=rows) |
|
366
|
analyzer = _analyzer(store=store) |
|
367
|
syms = [ |
|
368
|
{"name": "alpha", "file_path": "a.py"}, |
|
369
|
{"name": "beta", "file_path": "b.py"}, |
|
370
|
] |
|
371
|
with patch.object(analyzer, "affected_symbols", return_value=syms): |
|
372
|
result = analyzer.affected_knowledge() |
|
373
|
assert len([k for k in result if k["name"] == "SharedConcept"]) == 1 |
|
374
|
|
|
375
|
def test_knowledge_dict_has_required_keys(self): |
|
376
|
rows = [("Rule", "my_rule", "some desc", "payments", "")] |
|
377
|
store = _mock_store(result_set=rows) |
|
378
|
analyzer = _analyzer(store=store) |
|
379
|
sym = [{"name": "process", "file_path": "pay.py"}] |
|
380
|
with patch.object(analyzer, "affected_symbols", return_value=sym): |
|
381
|
result = analyzer.affected_knowledge() |
|
382
|
assert len(result) == 1 |
|
383
|
k = result[0] |
|
384
|
assert "type" in k |
|
385
|
assert "name" in k |
|
386
|
assert "description" in k |
|
387
|
assert "domain" in k |
|
388
|
assert "status" in k |
|
389
|
|
|
390
|
|
|
391
|
# ── DiffAnalyzer.impact_summary ─────────────────────────────────────────────── |
|
392
|
|
|
393
|
|
|
394
|
class TestDiffAnalyzerImpactSummary: |
|
395
|
def _build(self, files=None, symbols=None, knowledge=None): |
|
396
|
store = _mock_store() |
|
397
|
analyzer = _analyzer(store=store) |
|
398
|
with ( |
|
399
|
patch.object(analyzer, "changed_files", return_value=files or []), |
|
400
|
patch.object(analyzer, "affected_symbols", return_value=symbols or []), |
|
401
|
patch.object(analyzer, "affected_knowledge", return_value=knowledge or []), |
|
402
|
): |
|
403
|
return analyzer.impact_summary() |
|
404
|
|
|
405
|
def test_returns_dict(self): |
|
406
|
result = self._build() |
|
407
|
assert isinstance(result, dict) |
|
408
|
|
|
409
|
def test_has_all_top_level_keys(self): |
|
410
|
result = self._build() |
|
411
|
assert "files" in result |
|
412
|
assert "symbols" in result |
|
413
|
assert "knowledge" in result |
|
414
|
assert "counts" in result |
|
415
|
|
|
416
|
def test_counts_match_lengths(self): |
|
417
|
files = ["a.py", "b.py"] |
|
418
|
symbols = [{"type": "Function", "name": "f", "file_path": "a.py", |
|
419
|
"line_start": 1, "line_end": 5}] |
|
420
|
knowledge = [{"type": "Concept", "name": "X", "description": "", |
|
421
|
"domain": "", "status": ""}] |
|
422
|
result = self._build(files=files, symbols=symbols, knowledge=knowledge) |
|
423
|
assert result["counts"]["files"] == 2 |
|
424
|
assert result["counts"]["symbols"] == 1 |
|
425
|
assert result["counts"]["knowledge"] == 1 |
|
426
|
|
|
427
|
def test_empty_summary_all_zeros(self): |
|
428
|
result = self._build() |
|
429
|
assert result["counts"]["files"] == 0 |
|
430
|
assert result["counts"]["symbols"] == 0 |
|
431
|
assert result["counts"]["knowledge"] == 0 |
|
432
|
|
|
433
|
def test_files_list_propagated(self): |
|
434
|
result = self._build(files=["x.py", "y.py"]) |
|
435
|
assert result["files"] == ["x.py", "y.py"] |
|
436
|
|
|
437
|
|
|
438
|
# ── DiffAnalyzer.to_json ────────────────────────────────────────────────────── |
|
439
|
|
|
440
|
|
|
441
|
class TestDiffAnalyzerToJson: |
|
442
|
def test_returns_valid_json(self): |
|
443
|
store = _mock_store() |
|
444
|
analyzer = _analyzer(store=store) |
|
445
|
summary = {"files": [], "symbols": [], "knowledge": [], "counts": {"files": 0}} |
|
446
|
with patch.object(analyzer, "impact_summary", return_value=summary): |
|
447
|
output = analyzer.to_json() |
|
448
|
parsed = json.loads(output) |
|
449
|
assert isinstance(parsed, dict) |
|
450
|
|
|
451
|
def test_json_contains_summary_keys(self): |
|
452
|
store = _mock_store() |
|
453
|
analyzer = _analyzer(store=store) |
|
454
|
summary = {"files": ["f.py"], "symbols": [], "knowledge": [], "counts": {"files": 1}} |
|
455
|
with patch.object(analyzer, "impact_summary", return_value=summary): |
|
456
|
output = analyzer.to_json() |
|
457
|
parsed = json.loads(output) |
|
458
|
assert "files" in parsed |
|
459
|
|
|
460
|
|
|
461
|
# ── DiffAnalyzer.to_markdown ────────────────────────────────────────────────── |
|
462
|
|
|
463
|
|
|
464
|
class TestDiffAnalyzerToMarkdown: |
|
465
|
def _md(self, files=None, symbols=None, knowledge=None): |
|
466
|
store = _mock_store() |
|
467
|
analyzer = _analyzer(store=store) |
|
468
|
summary = { |
|
469
|
"files": files or [], |
|
470
|
"symbols": symbols or [], |
|
471
|
"knowledge": knowledge or [], |
|
472
|
"counts": { |
|
473
|
"files": len(files or []), |
|
474
|
"symbols": len(symbols or []), |
|
475
|
"knowledge": len(knowledge or []), |
|
476
|
}, |
|
477
|
} |
|
478
|
with patch.object(analyzer, "impact_summary", return_value=summary): |
|
479
|
return analyzer.to_markdown() |
|
480
|
|
|
481
|
def test_returns_string(self): |
|
482
|
assert isinstance(self._md(), str) |
|
483
|
|
|
484
|
def test_contains_heading(self): |
|
485
|
assert "Diff Impact Summary" in self._md() |
|
486
|
|
|
487
|
def test_lists_changed_file(self): |
|
488
|
md = self._md(files=["src/main.py"]) |
|
489
|
assert "src/main.py" in md |
|
490
|
|
|
491
|
def test_lists_affected_symbol(self): |
|
492
|
syms = [{"type": "Function", "name": "pay", "file_path": "billing.py", |
|
493
|
"line_start": 10, "line_end": 20}] |
|
494
|
md = self._md(symbols=syms) |
|
495
|
assert "pay" in md |
|
496
|
|
|
497
|
def test_lists_knowledge_node(self): |
|
498
|
know = [{"type": "Rule", "name": "no_double_charge", |
|
499
|
"description": "desc", "domain": "", "status": ""}] |
|
500
|
md = self._md(knowledge=know) |
|
501
|
assert "no_double_charge" in md |
|
502
|
|
|
503
|
def test_empty_sections_show_placeholder(self): |
|
504
|
md = self._md() |
|
505
|
assert "No changed files" in md |
|
506
|
assert "No affected symbols" in md |
|
507
|
assert "No linked knowledge" in md |
|
508
|
|
|
509
|
|
|
510
|
# ── CLI: navegador diff ──────────────────────────────────────────────────────── |
|
511
|
|
|
512
|
|
|
513
|
class TestCLIDiffCommand: |
|
514
|
def _runner(self): |
|
515
|
return CliRunner() |
|
516
|
|
|
517
|
def _mock_analyzer(self, summary=None): |
|
518
|
"""Patch DiffAnalyzer so it never touches git or the graph.""" |
|
519
|
if summary is None: |
|
520
|
summary = { |
|
521
|
"files": ["app.py"], |
|
522
|
"symbols": [{"type": "Function", "name": "run", |
|
523
|
"file_path": "app.py", "line_start": 1, "line_end": 10}], |
|
524
|
"knowledge": [], |
|
525
|
"counts": {"files": 1, "symbols": 1, "knowledge": 0}, |
|
526
|
} |
|
527
|
mock_inst = MagicMock() |
|
528
|
mock_inst.impact_summary.return_value = summary |
|
529
|
mock_inst.to_json.return_value = json.dumps(summary, indent=2) |
|
530
|
mock_inst.to_markdown.return_value = "# Diff Impact Summary\n\n## Changed Files (1)" |
|
531
|
return mock_inst |
|
532
|
|
|
533
|
def test_command_exists(self): |
|
534
|
runner = self._runner() |
|
535
|
result = runner.invoke(main, ["diff", "--help"]) |
|
536
|
assert result.exit_code == 0 |
|
537
|
|
|
538
|
def test_markdown_output_by_default(self, tmp_path): |
|
539
|
runner = self._runner() |
|
540
|
mock_inst = self._mock_analyzer() |
|
541
|
with ( |
|
542
|
runner.isolated_filesystem(), |
|
543
|
patch("navegador.cli.commands._get_store", return_value=_mock_store()), |
|
544
|
patch("navegador.diff.DiffAnalyzer", return_value=mock_inst), |
|
545
|
): |
|
546
|
result = runner.invoke(main, ["diff", "--repo", str(tmp_path)]) |
|
547
|
assert result.exit_code == 0 |
|
548
|
assert "Diff Impact Summary" in result.output |
|
549
|
|
|
550
|
def test_json_output_flag(self, tmp_path): |
|
551
|
runner = self._runner() |
|
552
|
mock_inst = self._mock_analyzer() |
|
553
|
with ( |
|
554
|
runner.isolated_filesystem(), |
|
555
|
patch("navegador.cli.commands._get_store", return_value=_mock_store()), |
|
556
|
patch("navegador.diff.DiffAnalyzer", return_value=mock_inst), |
|
557
|
): |
|
558
|
result = runner.invoke(main, ["diff", "--format", "json", "--repo", str(tmp_path)]) |
|
559
|
assert result.exit_code == 0 |
|
560
|
parsed = json.loads(result.output) |
|
561
|
assert "files" in parsed |
|
562
|
|
|
563
|
def test_json_is_valid(self, tmp_path): |
|
564
|
runner = self._runner() |
|
565
|
summary = { |
|
566
|
"files": ["x.py"], |
|
567
|
"symbols": [], |
|
568
|
"knowledge": [], |
|
569
|
"counts": {"files": 1, "symbols": 0, "knowledge": 0}, |
|
570
|
} |
|
571
|
mock_inst = self._mock_analyzer(summary=summary) |
|
572
|
with ( |
|
573
|
runner.isolated_filesystem(), |
|
574
|
patch("navegador.cli.commands._get_store", return_value=_mock_store()), |
|
575
|
patch("navegador.diff.DiffAnalyzer", return_value=mock_inst), |
|
576
|
): |
|
577
|
result = runner.invoke(main, ["diff", "--format", "json", "--repo", str(tmp_path)]) |
|
578
|
assert result.exit_code == 0 |
|
579
|
data = json.loads(result.output) |
|
580
|
assert data["files"] == ["x.py"] |
|
581
|
assert data["counts"]["files"] == 1 |
|
582
|
|