Navegador

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

Keyboard Shortcuts

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