cgcardona / muse public
test_core_bisect.py python
287 lines 9.6 KB
e0353dfe feat: muse reflog, gc, archive, bisect, blame, worktree, workspace Gabriel Cardona <cgcardona@gmail.com> 7h ago
1 """Tests for muse/core/bisect.py — binary search regression hunting."""
2
3 from __future__ import annotations
4
5 import json
6 import pathlib
7
8 import pytest
9
10 from muse.core.bisect import (
11 BisectResult,
12 get_bisect_log,
13 is_bisect_active,
14 mark_bad,
15 mark_good,
16 reset_bisect,
17 skip_commit,
18 start_bisect,
19 )
20
21
22 # ---------------------------------------------------------------------------
23 # Repo fixture
24 # ---------------------------------------------------------------------------
25
26
27 def _make_linear_repo(tmp_path: pathlib.Path, n: int = 8) -> list[str]:
28 """Create n commits in a linear chain; return commit IDs oldest-first."""
29 import datetime
30
31 muse = tmp_path / ".muse"
32 for d in ("objects", "commits", "snapshots", "refs/heads"):
33 (muse / d).mkdir(parents=True, exist_ok=True)
34 (muse / "repo.json").write_text(json.dumps({"repo_id": "test"}))
35 (muse / "HEAD").write_text("refs/heads/main\n")
36
37 commit_ids: list[str] = []
38 parent: str | None = None
39 for i in range(n):
40 commit_id = format(i + 1, "064x")
41 snap_id = format(100 + i, "064x")
42 rec: dict[str, str | None | dict[str, str]] = {
43 "commit_id": commit_id,
44 "repo_id": "test",
45 "branch": "main",
46 "snapshot_id": snap_id,
47 "message": f"commit {i + 1}",
48 "committed_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
49 "parent_commit_id": parent,
50 "parent2_commit_id": None,
51 "author": "Test",
52 "metadata": {},
53 }
54 (muse / "commits" / f"{commit_id}.json").write_text(json.dumps(rec))
55 snap: dict[str, str | dict[str, str]] = {"snapshot_id": snap_id, "manifest": {}}
56 (muse / "snapshots" / f"{snap_id}.json").write_text(json.dumps(snap))
57 commit_ids.append(commit_id)
58 parent = commit_id
59
60 (muse / "refs" / "heads" / "main").write_text(commit_ids[-1])
61 return commit_ids
62
63
64 # ---------------------------------------------------------------------------
65 # start_bisect
66 # ---------------------------------------------------------------------------
67
68
69 def test_start_bisect_creates_state(tmp_path: pathlib.Path) -> None:
70 commits = _make_linear_repo(tmp_path)
71 bad_id = commits[-1]
72 good_id = commits[0]
73 result = start_bisect(tmp_path, bad_id, [good_id])
74 assert is_bisect_active(tmp_path)
75 assert isinstance(result, BisectResult)
76
77
78 def test_start_bisect_suggests_midpoint(tmp_path: pathlib.Path) -> None:
79 commits = _make_linear_repo(tmp_path, n=8)
80 result = start_bisect(tmp_path, commits[-1], [commits[0]])
81 assert result.next_to_test is not None
82 assert not result.done
83
84
85 def test_start_bisect_steps_remaining_positive(tmp_path: pathlib.Path) -> None:
86 commits = _make_linear_repo(tmp_path, n=16)
87 result = start_bisect(tmp_path, commits[-1], [commits[0]])
88 assert result.steps_remaining > 0
89
90
91 def test_start_bisect_with_multiple_good(tmp_path: pathlib.Path) -> None:
92 commits = _make_linear_repo(tmp_path, n=10)
93 result = start_bisect(tmp_path, commits[-1], [commits[0], commits[2]])
94 assert result.next_to_test is not None
95
96
97 # ---------------------------------------------------------------------------
98 # mark_good / mark_bad
99 # ---------------------------------------------------------------------------
100
101
102 def test_mark_good_advances_bisect(tmp_path: pathlib.Path) -> None:
103 commits = _make_linear_repo(tmp_path, n=8)
104 start_bisect(tmp_path, commits[-1], [commits[0]])
105 # Read the midpoint from state.
106 from muse.core.bisect import _load_state
107 state = _load_state(tmp_path)
108 assert state is not None
109 remaining = state.get("remaining", [])
110 mid = remaining[len(remaining) // 2]
111 result = mark_good(tmp_path, mid)
112 assert isinstance(result, BisectResult)
113 assert result.verdict == "good"
114
115
116 def test_mark_bad_advances_bisect(tmp_path: pathlib.Path) -> None:
117 commits = _make_linear_repo(tmp_path, n=8)
118 start_bisect(tmp_path, commits[-1], [commits[0]])
119 from muse.core.bisect import _load_state
120 state = _load_state(tmp_path)
121 assert state is not None
122 remaining = state.get("remaining", [])
123 mid = remaining[len(remaining) // 2]
124 result = mark_bad(tmp_path, mid)
125 assert result.verdict == "bad"
126
127
128 def test_mark_good_reduces_remaining(tmp_path: pathlib.Path) -> None:
129 commits = _make_linear_repo(tmp_path, n=16)
130 start_bisect(tmp_path, commits[-1], [commits[0]])
131 from muse.core.bisect import _load_state
132 state = _load_state(tmp_path)
133 assert state is not None
134 remaining_before = len(state.get("remaining", []))
135 mid = state["remaining"][len(state["remaining"]) // 2]
136 result = mark_good(tmp_path, mid)
137 assert result.remaining_count < remaining_before
138
139
140 # ---------------------------------------------------------------------------
141 # skip_commit
142 # ---------------------------------------------------------------------------
143
144
145 def test_skip_commit(tmp_path: pathlib.Path) -> None:
146 commits = _make_linear_repo(tmp_path, n=8)
147 start_bisect(tmp_path, commits[-1], [commits[0]])
148 from muse.core.bisect import _load_state
149 state = _load_state(tmp_path)
150 assert state is not None
151 remaining = state.get("remaining", [])
152 mid = remaining[len(remaining) // 2]
153 result = skip_commit(tmp_path, mid)
154 assert result.verdict == "skip"
155
156
157 # ---------------------------------------------------------------------------
158 # reset_bisect
159 # ---------------------------------------------------------------------------
160
161
162 def test_reset_bisect_removes_state(tmp_path: pathlib.Path) -> None:
163 commits = _make_linear_repo(tmp_path)
164 start_bisect(tmp_path, commits[-1], [commits[0]])
165 assert is_bisect_active(tmp_path)
166 reset_bisect(tmp_path)
167 assert not is_bisect_active(tmp_path)
168
169
170 def test_reset_idempotent(tmp_path: pathlib.Path) -> None:
171 reset_bisect(tmp_path) # Should not raise even with no active session.
172
173
174 # ---------------------------------------------------------------------------
175 # bisect log
176 # ---------------------------------------------------------------------------
177
178
179 def test_bisect_log_records_start(tmp_path: pathlib.Path) -> None:
180 commits = _make_linear_repo(tmp_path)
181 start_bisect(tmp_path, commits[-1], [commits[0]])
182 log = get_bisect_log(tmp_path)
183 assert len(log) >= 2 # bad + at least one good
184
185
186 def test_bisect_log_records_verdicts(tmp_path: pathlib.Path) -> None:
187 commits = _make_linear_repo(tmp_path, n=8)
188 start_bisect(tmp_path, commits[-1], [commits[0]])
189 from muse.core.bisect import _load_state
190 state = _load_state(tmp_path)
191 assert state is not None
192 remaining = state.get("remaining", [])
193 mark_good(tmp_path, remaining[len(remaining) // 2])
194 log = get_bisect_log(tmp_path)
195 assert any("good" in entry for entry in log)
196
197
198 def test_bisect_log_empty_when_inactive(tmp_path: pathlib.Path) -> None:
199 assert get_bisect_log(tmp_path) == []
200
201
202 # ---------------------------------------------------------------------------
203 # is_bisect_active
204 # ---------------------------------------------------------------------------
205
206
207 def test_is_bisect_active_false_initially(tmp_path: pathlib.Path) -> None:
208 _make_linear_repo(tmp_path)
209 assert not is_bisect_active(tmp_path)
210
211
212 def test_is_bisect_active_true_after_start(tmp_path: pathlib.Path) -> None:
213 commits = _make_linear_repo(tmp_path)
214 start_bisect(tmp_path, commits[-1], [commits[0]])
215 assert is_bisect_active(tmp_path)
216
217
218 # ---------------------------------------------------------------------------
219 # Full convergence test
220 # ---------------------------------------------------------------------------
221
222
223 def test_bisect_converges_to_first_bad(tmp_path: pathlib.Path) -> None:
224 """Bisect should isolate commit 6 (0-indexed 5) as first bad in 8-commit chain."""
225 commits = _make_linear_repo(tmp_path, n=8)
226 # Commits 0..4 are good; commit 5 is the first bad.
227 bad_idx = 5
228
229 start_bisect(tmp_path, commits[-1], [commits[0]])
230
231 steps = 0
232 max_steps = 20 # guard against infinite loop in tests
233 while steps < max_steps:
234 from muse.core.bisect import _load_state
235 state = _load_state(tmp_path)
236 assert state is not None
237 remaining = state.get("remaining", [])
238 if not remaining:
239 break
240 mid = remaining[len(remaining) // 2]
241 mid_idx = commits.index(mid)
242 if mid_idx < bad_idx:
243 mark_good(tmp_path, mid)
244 else:
245 mark_bad(tmp_path, mid)
246 steps += 1
247
248 from muse.core.bisect import _load_state
249 final = _load_state(tmp_path)
250 assert final is not None
251 first_bad = final.get("bad_id", "")
252 # The first bad commit should be at or near index bad_idx.
253 assert first_bad in commits[bad_idx:]
254
255
256 # ---------------------------------------------------------------------------
257 # Stress: many commits
258 # ---------------------------------------------------------------------------
259
260
261 def test_bisect_stress_100_commits(tmp_path: pathlib.Path) -> None:
262 """Bisect should converge in at most log2(100) ≈ 7 steps for 100 commits."""
263 import math
264
265 commits = _make_linear_repo(tmp_path, n=100)
266 bad_idx = 60
267 start_bisect(tmp_path, commits[-1], [commits[0]])
268
269 steps = 0
270 max_steps = int(math.log2(100)) + 5
271 from muse.core.bisect import _load_state
272 while steps < max_steps:
273 state = _load_state(tmp_path)
274 if state is None:
275 break
276 remaining = state.get("remaining", [])
277 if not remaining:
278 break
279 mid = remaining[len(remaining) // 2]
280 mid_idx = commits.index(mid)
281 if mid_idx < bad_idx:
282 mark_good(tmp_path, mid)
283 else:
284 mark_bad(tmp_path, mid)
285 steps += 1
286
287 assert steps <= max_steps