Navegador

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

Keyboard Shortcuts

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