Navegador

navegador / tests / test_vcs.py
Source Blame History 333 lines
31642ed… lmata 1 """Tests for navegador.vcs — VCSAdapter, GitAdapter, FossilAdapter, detect_vcs."""
31642ed… lmata 2
31642ed… lmata 3 from __future__ import annotations
31642ed… lmata 4
31642ed… lmata 5 import subprocess
31642ed… lmata 6 from pathlib import Path
31642ed… lmata 7
31642ed… lmata 8 import pytest
31642ed… lmata 9
31642ed… lmata 10 from navegador.vcs import (
31642ed… lmata 11 FossilAdapter,
31642ed… lmata 12 GitAdapter,
31642ed… lmata 13 VCSAdapter,
31642ed… lmata 14 detect_vcs,
31642ed… lmata 15 )
31642ed… lmata 16
31642ed… lmata 17
31642ed… lmata 18 # ── Fixtures ───────────────────────────────────────────────────────────────────
31642ed… lmata 19
31642ed… lmata 20
31642ed… lmata 21 def _git(args: list[str], cwd: Path) -> subprocess.CompletedProcess:
31642ed… lmata 22 """Run a git command in *cwd*, raising on failure."""
31642ed… lmata 23 return subprocess.run(
31642ed… lmata 24 ["git", *args],
31642ed… lmata 25 cwd=cwd,
31642ed… lmata 26 capture_output=True,
31642ed… lmata 27 text=True,
31642ed… lmata 28 check=True,
31642ed… lmata 29 )
31642ed… lmata 30
31642ed… lmata 31
31642ed… lmata 32 @pytest.fixture()
31642ed… lmata 33 def git_repo(tmp_path: Path) -> Path:
31642ed… lmata 34 """
31642ed… lmata 35 Create a minimal, fully-initialised git repo with one commit.
31642ed… lmata 36
31642ed… lmata 37 Returns the repo root path.
31642ed… lmata 38 """
31642ed… lmata 39 repo = tmp_path / "repo"
31642ed… lmata 40 repo.mkdir()
31642ed… lmata 41
31642ed… lmata 42 _git(["init", "-b", "main"], cwd=repo)
31642ed… lmata 43 _git(["config", "user.email", "[email protected]"], cwd=repo)
31642ed… lmata 44 _git(["config", "user.name", "Test User"], cwd=repo)
31642ed… lmata 45
31642ed… lmata 46 # Initial commit so HEAD exists
31642ed… lmata 47 readme = repo / "README.md"
31642ed… lmata 48 readme.write_text("# test repo\n")
31642ed… lmata 49 _git(["add", "README.md"], cwd=repo)
31642ed… lmata 50 _git(["commit", "-m", "initial commit"], cwd=repo)
31642ed… lmata 51
31642ed… lmata 52 return repo
31642ed… lmata 53
31642ed… lmata 54
31642ed… lmata 55 @pytest.fixture()
31642ed… lmata 56 def empty_dir(tmp_path: Path) -> Path:
31642ed… lmata 57 """Return a temporary directory that is NOT a git repo."""
31642ed… lmata 58 d = tmp_path / "notarepo"
31642ed… lmata 59 d.mkdir()
31642ed… lmata 60 return d
31642ed… lmata 61
31642ed… lmata 62
31642ed… lmata 63 @pytest.fixture()
31642ed… lmata 64 def fossil_dir(tmp_path: Path) -> Path:
31642ed… lmata 65 """Return a directory that looks like a Fossil checkout (.fslckout present)."""
31642ed… lmata 66 d = tmp_path / "fossil_repo"
31642ed… lmata 67 d.mkdir()
31642ed… lmata 68 (d / ".fslckout").touch()
31642ed… lmata 69 return d
31642ed… lmata 70
31642ed… lmata 71
31642ed… lmata 72 # ── VCSAdapter is abstract ─────────────────────────────────────────────────────
31642ed… lmata 73
31642ed… lmata 74
31642ed… lmata 75 class TestVCSAdapterAbstract:
31642ed… lmata 76 def test_cannot_instantiate_directly(self):
31642ed… lmata 77 with pytest.raises(TypeError):
31642ed… lmata 78 VCSAdapter(Path("/tmp")) # type: ignore[abstract]
31642ed… lmata 79
31642ed… lmata 80 def test_git_adapter_is_subclass(self):
31642ed… lmata 81 assert issubclass(GitAdapter, VCSAdapter)
31642ed… lmata 82
31642ed… lmata 83 def test_fossil_adapter_is_subclass(self):
31642ed… lmata 84 assert issubclass(FossilAdapter, VCSAdapter)
31642ed… lmata 85
31642ed… lmata 86
31642ed… lmata 87 # ── GitAdapter.is_repo ─────────────────────────────────────────────────────────
31642ed… lmata 88
31642ed… lmata 89
31642ed… lmata 90 class TestGitAdapterIsRepo:
31642ed… lmata 91 def test_true_for_git_repo(self, git_repo: Path):
31642ed… lmata 92 assert GitAdapter(git_repo).is_repo() is True
31642ed… lmata 93
31642ed… lmata 94 def test_false_for_empty_dir(self, empty_dir: Path):
31642ed… lmata 95 assert GitAdapter(empty_dir).is_repo() is False
31642ed… lmata 96
31642ed… lmata 97 def test_false_for_fossil_dir(self, fossil_dir: Path):
31642ed… lmata 98 assert GitAdapter(fossil_dir).is_repo() is False
31642ed… lmata 99
31642ed… lmata 100
31642ed… lmata 101 # ── GitAdapter.current_branch ──────────────────────────────────────────────────
31642ed… lmata 102
31642ed… lmata 103
31642ed… lmata 104 class TestGitAdapterCurrentBranch:
31642ed… lmata 105 def test_returns_main(self, git_repo: Path):
31642ed… lmata 106 branch = GitAdapter(git_repo).current_branch()
31642ed… lmata 107 # Accept "main" or "master" depending on git config defaults
31642ed… lmata 108 assert branch in ("main", "master")
31642ed… lmata 109
31642ed… lmata 110 def test_returns_feature_branch(self, git_repo: Path):
31642ed… lmata 111 _git(["checkout", "-b", "feature/test-branch"], cwd=git_repo)
31642ed… lmata 112 branch = GitAdapter(git_repo).current_branch()
31642ed… lmata 113 assert branch == "feature/test-branch"
31642ed… lmata 114
31642ed… lmata 115 def test_returns_string(self, git_repo: Path):
31642ed… lmata 116 result = GitAdapter(git_repo).current_branch()
31642ed… lmata 117 assert isinstance(result, str)
31642ed… lmata 118 assert len(result) > 0
31642ed… lmata 119
31642ed… lmata 120
31642ed… lmata 121 # ── GitAdapter.changed_files ───────────────────────────────────────────────────
31642ed… lmata 122
31642ed… lmata 123
31642ed… lmata 124 class TestGitAdapterChangedFiles:
31642ed… lmata 125 def test_no_changes_returns_empty(self, git_repo: Path):
31642ed… lmata 126 # Nothing changed after the initial commit
31642ed… lmata 127 files = GitAdapter(git_repo).changed_files()
31642ed… lmata 128 assert files == []
31642ed… lmata 129
31642ed… lmata 130 def test_detects_modified_file(self, git_repo: Path):
31642ed… lmata 131 # Modify a tracked file without staging it
31642ed… lmata 132 readme = git_repo / "README.md"
31642ed… lmata 133 readme.write_text("# modified\n")
31642ed… lmata 134
31642ed… lmata 135 files = GitAdapter(git_repo).changed_files()
31642ed… lmata 136 assert "README.md" in files
31642ed… lmata 137
31642ed… lmata 138 def test_detects_staged_file(self, git_repo: Path):
31642ed… lmata 139 new_file = git_repo / "new.py"
31642ed… lmata 140 new_file.write_text("x = 1\n")
31642ed… lmata 141 _git(["add", "new.py"], cwd=git_repo)
31642ed… lmata 142
31642ed… lmata 143 files = GitAdapter(git_repo).changed_files()
31642ed… lmata 144 assert "new.py" in files
31642ed… lmata 145
31642ed… lmata 146 def test_since_commit_returns_changed_files(self, git_repo: Path):
31642ed… lmata 147 # Get the first commit hash
31642ed… lmata 148 result = _git(["rev-parse", "HEAD"], cwd=git_repo)
31642ed… lmata 149 first_hash = result.stdout.strip()
31642ed… lmata 150
31642ed… lmata 151 # Add a second commit
31642ed… lmata 152 extra = git_repo / "extra.txt"
31642ed… lmata 153 extra.write_text("hello\n")
31642ed… lmata 154 _git(["add", "extra.txt"], cwd=git_repo)
31642ed… lmata 155 _git(["commit", "-m", "add extra.txt"], cwd=git_repo)
31642ed… lmata 156
31642ed… lmata 157 files = GitAdapter(git_repo).changed_files(since=first_hash)
31642ed… lmata 158 assert "extra.txt" in files
31642ed… lmata 159
31642ed… lmata 160 def test_returns_list(self, git_repo: Path):
31642ed… lmata 161 result = GitAdapter(git_repo).changed_files()
31642ed… lmata 162 assert isinstance(result, list)
31642ed… lmata 163
31642ed… lmata 164
31642ed… lmata 165 # ── GitAdapter.file_history ────────────────────────────────────────────────────
31642ed… lmata 166
31642ed… lmata 167
31642ed… lmata 168 class TestGitAdapterFileHistory:
31642ed… lmata 169 def test_returns_list(self, git_repo: Path):
31642ed… lmata 170 history = GitAdapter(git_repo).file_history("README.md")
31642ed… lmata 171 assert isinstance(history, list)
31642ed… lmata 172
31642ed… lmata 173 def test_initial_commit_present(self, git_repo: Path):
31642ed… lmata 174 history = GitAdapter(git_repo).file_history("README.md")
31642ed… lmata 175 assert len(history) >= 1
31642ed… lmata 176
31642ed… lmata 177 def test_entry_has_required_keys(self, git_repo: Path):
31642ed… lmata 178 history = GitAdapter(git_repo).file_history("README.md")
31642ed… lmata 179 entry = history[0]
31642ed… lmata 180 assert "hash" in entry
31642ed… lmata 181 assert "author" in entry
31642ed… lmata 182 assert "date" in entry
31642ed… lmata 183 assert "message" in entry
31642ed… lmata 184
31642ed… lmata 185 def test_entry_message_matches(self, git_repo: Path):
31642ed… lmata 186 history = GitAdapter(git_repo).file_history("README.md")
31642ed… lmata 187 assert history[0]["message"] == "initial commit"
31642ed… lmata 188
31642ed… lmata 189 def test_limit_is_respected(self, git_repo: Path):
31642ed… lmata 190 # Add several more commits to README.md
31642ed… lmata 191 readme = git_repo / "README.md"
31642ed… lmata 192 for i in range(5):
31642ed… lmata 193 readme.write_text(f"# revision {i}\n")
31642ed… lmata 194 _git(["add", "README.md"], cwd=git_repo)
31642ed… lmata 195 _git(["commit", "-m", f"revision {i}"], cwd=git_repo)
31642ed… lmata 196
31642ed… lmata 197 history = GitAdapter(git_repo).file_history("README.md", limit=3)
31642ed… lmata 198 assert len(history) <= 3
31642ed… lmata 199
31642ed… lmata 200 def test_nonexistent_file_returns_empty(self, git_repo: Path):
31642ed… lmata 201 history = GitAdapter(git_repo).file_history("does_not_exist.py")
31642ed… lmata 202 assert history == []
31642ed… lmata 203
31642ed… lmata 204
31642ed… lmata 205 # ── GitAdapter.blame ──────────────────────────────────────────────────────────
31642ed… lmata 206
31642ed… lmata 207
31642ed… lmata 208 class TestGitAdapterBlame:
31642ed… lmata 209 def test_returns_list(self, git_repo: Path):
31642ed… lmata 210 result = GitAdapter(git_repo).blame("README.md")
31642ed… lmata 211 assert isinstance(result, list)
31642ed… lmata 212
31642ed… lmata 213 def test_entry_has_required_keys(self, git_repo: Path):
31642ed… lmata 214 result = GitAdapter(git_repo).blame("README.md")
31642ed… lmata 215 assert len(result) >= 1
31642ed… lmata 216 entry = result[0]
31642ed… lmata 217 assert "line" in entry
31642ed… lmata 218 assert "hash" in entry
31642ed… lmata 219 assert "author" in entry
31642ed… lmata 220 assert "content" in entry
31642ed… lmata 221
31642ed… lmata 222 def test_content_matches_file(self, git_repo: Path):
31642ed… lmata 223 result = GitAdapter(git_repo).blame("README.md")
31642ed… lmata 224 # README.md contains "# test repo"
31642ed… lmata 225 contents = [e["content"] for e in result]
31642ed… lmata 226 assert any("test repo" in c for c in contents)
31642ed… lmata 227
31642ed… lmata 228 def test_line_numbers_are_integers(self, git_repo: Path):
31642ed… lmata 229 result = GitAdapter(git_repo).blame("README.md")
31642ed… lmata 230 for entry in result:
31642ed… lmata 231 assert isinstance(entry["line"], int)
31642ed… lmata 232
31642ed… lmata 233
31642ed… lmata 234 # ── FossilAdapter.is_repo ─────────────────────────────────────────────────────
31642ed… lmata 235
31642ed… lmata 236
31642ed… lmata 237 class TestFossilAdapterIsRepo:
31642ed… lmata 238 def test_true_when_fslckout_present(self, fossil_dir: Path):
31642ed… lmata 239 assert FossilAdapter(fossil_dir).is_repo() is True
31642ed… lmata 240
31642ed… lmata 241 def test_true_when_FOSSIL_present(self, tmp_path: Path):
31642ed… lmata 242 d = tmp_path / "fossil2"
31642ed… lmata 243 d.mkdir()
31642ed… lmata 244 (d / "_FOSSIL_").touch()
31642ed… lmata 245 assert FossilAdapter(d).is_repo() is True
31642ed… lmata 246
31642ed… lmata 247 def test_false_for_empty_dir(self, empty_dir: Path):
31642ed… lmata 248 assert FossilAdapter(empty_dir).is_repo() is False
31642ed… lmata 249
31642ed… lmata 250 def test_false_for_git_repo(self, git_repo: Path):
31642ed… lmata 251 assert FossilAdapter(git_repo).is_repo() is False
31642ed… lmata 252
31642ed… lmata 253
1d4baaf… lmata 254 # ── FossilAdapter implemented methods (#55) ────────────────────────────────────
1d4baaf… lmata 255 #
1d4baaf… lmata 256 # These methods are now fully implemented; they call `fossil` via subprocess.
1d4baaf… lmata 257 # Since fossil may not be installed in CI, we mock subprocess.run.
31642ed… lmata 258
31642ed… lmata 259
1d4baaf… lmata 260 class TestFossilAdapterImplemented:
1d4baaf… lmata 261 """FossilAdapter methods are implemented — they call fossil via subprocess."""
1d4baaf… lmata 262
31642ed… lmata 263 @pytest.fixture()
31642ed… lmata 264 def adapter(self, fossil_dir: Path) -> FossilAdapter:
31642ed… lmata 265 return FossilAdapter(fossil_dir)
31642ed… lmata 266
1d4baaf… lmata 267 def test_current_branch_returns_string(self, adapter: FossilAdapter):
1d4baaf… lmata 268 from unittest.mock import MagicMock, patch
1d4baaf… lmata 269
1d4baaf… lmata 270 mock_result = MagicMock()
1d4baaf… lmata 271 mock_result.stdout = "trunk\n"
1d4baaf… lmata 272 with patch("subprocess.run", return_value=mock_result):
1d4baaf… lmata 273 branch = adapter.current_branch()
1d4baaf… lmata 274 assert branch == "trunk"
1d4baaf… lmata 275
1d4baaf… lmata 276 def test_changed_files_returns_list(self, adapter: FossilAdapter):
1d4baaf… lmata 277 from unittest.mock import MagicMock, patch
1d4baaf… lmata 278
1d4baaf… lmata 279 mock_result = MagicMock()
1d4baaf… lmata 280 mock_result.stdout = "EDITED src/main.py\n"
1d4baaf… lmata 281 with patch("subprocess.run", return_value=mock_result):
1d4baaf… lmata 282 files = adapter.changed_files()
1d4baaf… lmata 283 assert isinstance(files, list)
1d4baaf… lmata 284 assert "src/main.py" in files
1d4baaf… lmata 285
1d4baaf… lmata 286 def test_file_history_returns_list(self, adapter: FossilAdapter):
1d4baaf… lmata 287 from unittest.mock import MagicMock, patch
1d4baaf… lmata 288
1d4baaf… lmata 289 mock_result = MagicMock()
1d4baaf… lmata 290 mock_result.stdout = (
1d4baaf… lmata 291 "=== 2024-01-15 ===\n"
1d4baaf… lmata 292 "14:23:07 [abc123] Fix bug. (user: alice, tags: trunk)\n"
1d4baaf… lmata 293 )
1d4baaf… lmata 294 with patch("subprocess.run", return_value=mock_result):
1d4baaf… lmata 295 history = adapter.file_history("README.md")
1d4baaf… lmata 296 assert isinstance(history, list)
1d4baaf… lmata 297
1d4baaf… lmata 298 def test_blame_returns_list(self, adapter: FossilAdapter):
1d4baaf… lmata 299 from unittest.mock import MagicMock, patch
1d4baaf… lmata 300
1d4baaf… lmata 301 mock_result = MagicMock()
1d4baaf… lmata 302 mock_result.stdout = "1.1 alice 2024-01-15: # line content\n"
1d4baaf… lmata 303 with patch("subprocess.run", return_value=mock_result):
1d4baaf… lmata 304 result = adapter.blame("README.md")
1d4baaf… lmata 305 assert isinstance(result, list)
31642ed… lmata 306
31642ed… lmata 307
31642ed… lmata 308 # ── detect_vcs factory ─────────────────────────────────────────────────────────
31642ed… lmata 309
31642ed… lmata 310
31642ed… lmata 311 class TestDetectVCS:
31642ed… lmata 312 def test_detects_git(self, git_repo: Path):
31642ed… lmata 313 adapter = detect_vcs(git_repo)
31642ed… lmata 314 assert isinstance(adapter, GitAdapter)
31642ed… lmata 315
31642ed… lmata 316 def test_detects_fossil(self, fossil_dir: Path):
31642ed… lmata 317 adapter = detect_vcs(fossil_dir)
31642ed… lmata 318 assert isinstance(adapter, FossilAdapter)
31642ed… lmata 319
31642ed… lmata 320 def test_raises_for_unknown(self, empty_dir: Path):
31642ed… lmata 321 with pytest.raises(ValueError, match="No supported VCS"):
31642ed… lmata 322 detect_vcs(empty_dir)
31642ed… lmata 323
31642ed… lmata 324 def test_returned_adapter_has_correct_repo_path(self, git_repo: Path):
31642ed… lmata 325 adapter = detect_vcs(git_repo)
31642ed… lmata 326 assert adapter.repo_path == git_repo
31642ed… lmata 327
31642ed… lmata 328 def test_detects_fossil_via_FOSSIL_file(self, tmp_path: Path):
31642ed… lmata 329 d = tmp_path / "fossil_alt"
31642ed… lmata 330 d.mkdir()
31642ed… lmata 331 (d / "_FOSSIL_").touch()
31642ed… lmata 332 adapter = detect_vcs(d)
31642ed… lmata 333 assert isinstance(adapter, FossilAdapter)

Keyboard Shortcuts

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