Navegador

navegador / tests / test_diff.py
Blame History Raw 582 lines
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

Keyboard Shortcuts

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