gabriel / muse public
test_core_test_history.py python
433 lines 15.0 KB
Raw
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
1 """Tests for muse.core.test_history — persistent test-run history.
2
3 Coverage:
4 - Unit tests for serialisation (_record_to_msgpack / _record_from_msgpack).
5 - Round-trip tests: save + load round-trips for RunRecord.
6 - load_history returns empty list when file missing.
7 - append_run adds one record.
8 - summarize computes correct counts, flaky flag, and fail_streak.
9 - flaky_tests returns only flaky tests, sorted by fail_count.
10 - prioritize_targets puts streaky/flaky tests first.
11 - Corrupt file handling: load_history returns empty list on corruption.
12 - iso_now returns a valid ISO 8601 string.
13 - make_run_id returns a unique UUID.
14 """
15
16 from __future__ import annotations
17
18 import pathlib
19 import uuid
20
21 import pytest
22
23 from muse.core.test_history import (
24 HistorySummary,
25 RunRecord,
26 CaseRecord,
27 _record_from_msgpack,
28 _record_to_msgpack,
29 append_run,
30 flaky_tests,
31 iso_now,
32 load_history,
33 make_run_id,
34 prioritize_targets,
35 save_history,
36 summarize,
37 )
38
39
40 # ---------------------------------------------------------------------------
41 # Fixtures
42 # ---------------------------------------------------------------------------
43
44
45 def _make_record(
46 run_id: str = "run-1",
47 *,
48 passed: int = 2,
49 failed: int = 0,
50 results: list[CaseRecord] | None = None,
51 ) -> RunRecord:
52 if results is None:
53 results = [
54 CaseRecord(
55 node_id="tests/test_foo.py::test_a",
56 outcome="passed",
57 duration_ms=10.0,
58 symbol_addresses=[],
59 ),
60 CaseRecord(
61 node_id="tests/test_foo.py::test_b",
62 outcome="passed",
63 duration_ms=20.0,
64 symbol_addresses=[],
65 ),
66 ]
67 return RunRecord(
68 run_id=run_id,
69 timestamp="2026-03-26T12:00:00Z",
70 commit_id="abc123",
71 branch="main",
72 results=results,
73 total=len(results),
74 passed=passed,
75 failed=failed,
76 errored=0,
77 skipped=0,
78 )
79
80
81 # ---------------------------------------------------------------------------
82 # Unit tests — serialisation
83 # ---------------------------------------------------------------------------
84
85
86 class TestRecordSerialization:
87 def test_round_trip(self) -> None:
88 """A RunRecord serialises and deserialises back to an equal value."""
89 import msgpack
90 record = _make_record()
91 # Round-trip through msgpack bytes to get a MsgpackValue for _record_from_msgpack.
92 doc = _record_to_msgpack(record)
93 raw_bytes = msgpack.packb(doc, use_bin_type=True)
94 raw_value = msgpack.unpackb(raw_bytes, raw=False)
95 restored = _record_from_msgpack(raw_value)
96 assert restored is not None
97 assert restored["run_id"] == record["run_id"]
98 assert restored["timestamp"] == record["timestamp"]
99 assert restored["commit_id"] == record["commit_id"]
100 assert restored["branch"] == record["branch"]
101 assert restored["total"] == record["total"]
102 assert restored["passed"] == record["passed"]
103 assert len(restored["results"]) == len(record["results"])
104
105 def test_longrepr_round_trip(self) -> None:
106 """longrepr is preserved across serialisation."""
107 import msgpack as _msgpack
108 result = CaseRecord(
109 node_id="tests/test_foo.py::test_fail",
110 outcome="failed",
111 duration_ms=5.0,
112 symbol_addresses=[],
113 )
114 result["longrepr"] = "AssertionError: expected 1, got 2"
115
116 record = _make_record(
117 failed=1, passed=0, results=[result]
118 )
119 doc = _record_to_msgpack(record)
120 raw_bytes = _msgpack.packb(doc, use_bin_type=True)
121 raw_value = _msgpack.unpackb(raw_bytes, raw=False)
122 restored = _record_from_msgpack(raw_value)
123 assert restored is not None
124 restored_result = restored["results"][0]
125 assert restored_result.get("longrepr") == "AssertionError: expected 1, got 2"
126
127 def test_none_fields_preserved(self) -> None:
128 """commit_id=None and branch=None survive round-trip."""
129 import msgpack as _msgpack
130 record = _make_record()
131 record["commit_id"] = None
132 record["branch"] = None
133 doc = _record_to_msgpack(record)
134 raw_bytes = _msgpack.packb(doc, use_bin_type=True)
135 raw_value = _msgpack.unpackb(raw_bytes, raw=False)
136 restored = _record_from_msgpack(raw_value)
137 assert restored is not None
138 assert restored["commit_id"] is None
139 assert restored["branch"] is None
140
141 def test_invalid_input_returns_none(self) -> None:
142 """_record_from_msgpack returns None for non-dict input."""
143 assert _record_from_msgpack("not a dict") is None
144 assert _record_from_msgpack([]) is None
145 assert _record_from_msgpack(None) is None
146
147
148 # ---------------------------------------------------------------------------
149 # I/O tests — load_history / save_history / append_run
150 # ---------------------------------------------------------------------------
151
152
153 class TestLoadSave:
154 def test_load_missing_file(self, tmp_path: pathlib.Path) -> None:
155 """load_history returns [] when history file does not exist."""
156 (tmp_path / ".muse").mkdir()
157 records = load_history(tmp_path)
158 assert records == []
159
160 def test_save_and_load(self, tmp_path: pathlib.Path) -> None:
161 """save_history + load_history is a faithful round-trip."""
162 (tmp_path / ".muse").mkdir()
163 rec1 = _make_record("r1")
164 rec2 = _make_record("r2", passed=1, failed=1)
165 save_history(tmp_path, [rec1, rec2])
166 loaded = load_history(tmp_path)
167 assert len(loaded) == 2
168 assert loaded[0]["run_id"] == "r1"
169 assert loaded[1]["run_id"] == "r2"
170
171 def test_append_run(self, tmp_path: pathlib.Path) -> None:
172 """append_run adds one record to the history."""
173 (tmp_path / ".muse").mkdir()
174 save_history(tmp_path, [_make_record("r1")])
175 append_run(tmp_path, _make_record("r2"))
176 loaded = load_history(tmp_path)
177 assert len(loaded) == 2
178 assert loaded[-1]["run_id"] == "r2"
179
180 def test_load_corrupt_file_returns_empty(self, tmp_path: pathlib.Path) -> None:
181 """Corrupt msgpack file returns empty list without raising."""
182 muse_dir = tmp_path / ".muse"
183 muse_dir.mkdir()
184 hist_path = muse_dir / "test_history.msgpack"
185 hist_path.write_bytes(b"\xff\xfe garbage bytes that are not valid msgpack")
186 records = load_history(tmp_path)
187 assert records == []
188
189 def test_atomic_write(self, tmp_path: pathlib.Path) -> None:
190 """save_history writes to a .tmp file then renames (no partial writes)."""
191 (tmp_path / ".muse").mkdir()
192 save_history(tmp_path, [_make_record()])
193 tmp_files = list((tmp_path / ".muse").glob("*.tmp"))
194 assert tmp_files == [], "Temp file should be removed after atomic write"
195
196
197 # ---------------------------------------------------------------------------
198 # Analytics — summarize
199 # ---------------------------------------------------------------------------
200
201
202 class TestSummarize:
203 def test_empty_records(self) -> None:
204 """summarize returns empty dict for empty input."""
205 assert summarize([]) == {}
206
207 def test_all_passed(self) -> None:
208 """All-pass history: pass_count = total_runs, fail_count = 0."""
209 results = [
210 CaseRecord(
211 node_id="tests/test_foo.py::test_a",
212 outcome="passed",
213 duration_ms=10.0,
214 symbol_addresses=[],
215 )
216 ]
217 record = _make_record(passed=1, failed=0, results=results)
218 sums = summarize([record])
219 s = sums["tests/test_foo.py::test_a"]
220 assert s["pass_count"] == 1
221 assert s["fail_count"] == 0
222 assert s["flaky"] is False
223 assert s["fail_streak"] == 0
224 assert s["last_outcome"] == "passed"
225
226 def test_all_failed(self) -> None:
227 """All-fail history: fail_count = total_runs, fail_streak = total_runs."""
228 results = [
229 CaseRecord(
230 node_id="tests/test_foo.py::test_a",
231 outcome="failed",
232 duration_ms=5.0,
233 symbol_addresses=[],
234 )
235 ]
236 records = [
237 RunRecord(
238 run_id=f"r{i}",
239 timestamp=f"2026-03-{i+1:02d}T00:00:00Z",
240 commit_id=None,
241 branch=None,
242 results=results,
243 total=1,
244 passed=0,
245 failed=1,
246 errored=0,
247 skipped=0,
248 )
249 for i in range(3)
250 ]
251 sums = summarize(records)
252 s = sums["tests/test_foo.py::test_a"]
253 assert s["fail_count"] == 3
254 assert s["pass_count"] == 0
255 assert s["flaky"] is False
256 assert s["fail_streak"] == 3
257
258 def test_flaky_detection(self) -> None:
259 """A test that both passes and fails is flagged as flaky."""
260 pass_res = CaseRecord(
261 node_id="tests/test_foo.py::test_flaky",
262 outcome="passed",
263 duration_ms=10.0,
264 symbol_addresses=[],
265 )
266 fail_res = CaseRecord(
267 node_id="tests/test_foo.py::test_flaky",
268 outcome="failed",
269 duration_ms=10.0,
270 symbol_addresses=[],
271 )
272 records = [
273 _make_record("r1", passed=1, failed=0, results=[pass_res]),
274 _make_record("r2", passed=0, failed=1, results=[fail_res]),
275 ]
276 sums = summarize(records)
277 s = sums["tests/test_foo.py::test_flaky"]
278 assert s["flaky"] is True
279 assert s["pass_count"] == 1
280 assert s["fail_count"] == 1
281
282 def test_fail_streak_stops_on_pass(self) -> None:
283 """fail_streak resets when the most recent run passes."""
284 results_fail = [
285 CaseRecord(
286 node_id="tests/t.py::test_x",
287 outcome="failed",
288 duration_ms=5.0,
289 symbol_addresses=[],
290 )
291 ]
292 results_pass = [
293 CaseRecord(
294 node_id="tests/t.py::test_x",
295 outcome="passed",
296 duration_ms=5.0,
297 symbol_addresses=[],
298 )
299 ]
300 records = [
301 _make_record("r1", passed=0, failed=1, results=results_fail),
302 _make_record("r2", passed=0, failed=1, results=results_fail),
303 _make_record("r3", passed=1, failed=0, results=results_pass),
304 ]
305 sums = summarize(records)
306 s = sums["tests/t.py::test_x"]
307 assert s["fail_streak"] == 0 # Most recent run passed.
308
309 def test_avg_duration_excludes_skipped(self) -> None:
310 """avg_duration_ms excludes skipped tests from the mean."""
311 results = [
312 CaseRecord(
313 node_id="tests/t.py::test_x",
314 outcome="passed",
315 duration_ms=100.0,
316 symbol_addresses=[],
317 ),
318 CaseRecord(
319 node_id="tests/t.py::test_x",
320 outcome="skipped",
321 duration_ms=0.0,
322 symbol_addresses=[],
323 ),
324 ]
325 records = [
326 _make_record("r1", passed=1, results=[results[0]]),
327 _make_record("r2", passed=0, results=[results[1]]),
328 ]
329 sums = summarize(records)
330 s = sums["tests/t.py::test_x"]
331 assert s["avg_duration_ms"] == 100.0
332
333
334 # ---------------------------------------------------------------------------
335 # Analytics — flaky_tests
336 # ---------------------------------------------------------------------------
337
338
339 class TestFlakyTests:
340 def test_returns_only_flaky(self) -> None:
341 """flaky_tests returns only tests with both passes and failures."""
342 pass_res = CaseRecord(
343 node_id="tests/t.py::test_stable",
344 outcome="passed",
345 duration_ms=10.0,
346 symbol_addresses=[],
347 )
348 flaky_res_pass = CaseRecord(
349 node_id="tests/t.py::test_flaky",
350 outcome="passed",
351 duration_ms=10.0,
352 symbol_addresses=[],
353 )
354 flaky_res_fail = CaseRecord(
355 node_id="tests/t.py::test_flaky",
356 outcome="failed",
357 duration_ms=10.0,
358 symbol_addresses=[],
359 )
360 records = [
361 _make_record("r1", passed=2, results=[pass_res, flaky_res_pass]),
362 _make_record("r2", passed=1, failed=1, results=[pass_res, flaky_res_fail]),
363 ]
364 result = flaky_tests(records)
365 node_ids = {s["node_id"] for s in result}
366 assert "tests/t.py::test_flaky" in node_ids
367 assert "tests/t.py::test_stable" not in node_ids
368
369 def test_empty_returns_empty(self) -> None:
370 assert flaky_tests([]) == []
371
372
373 # ---------------------------------------------------------------------------
374 # Analytics — prioritize_targets
375 # ---------------------------------------------------------------------------
376
377
378 class TestPrioritizeTargets:
379 def test_unknown_targets_returned_in_some_order(self) -> None:
380 """Unknown targets (not in history) are returned (order unspecified)."""
381 targets = ["tests/t.py::test_a", "tests/t.py::test_b"]
382 result = prioritize_targets(targets, [])
383 assert sorted(result) == sorted(targets)
384
385 def test_streaky_test_comes_first(self) -> None:
386 """A test with a recent failure streak is sorted before stable tests."""
387 fail_res = CaseRecord(
388 node_id="tests/t.py::test_fail",
389 outcome="failed",
390 duration_ms=5.0,
391 symbol_addresses=[],
392 )
393 pass_res = CaseRecord(
394 node_id="tests/t.py::test_pass",
395 outcome="passed",
396 duration_ms=5.0,
397 symbol_addresses=[],
398 )
399 records = [
400 _make_record("r1", passed=0, failed=1, results=[fail_res]),
401 _make_record("r2", passed=1, failed=0, results=[pass_res]),
402 ]
403 targets = ["tests/t.py::test_pass", "tests/t.py::test_fail"]
404 ordered = prioritize_targets(targets, records)
405 assert ordered[0] == "tests/t.py::test_fail"
406
407 def test_empty_targets(self) -> None:
408 assert prioritize_targets([], []) == []
409
410
411 # ---------------------------------------------------------------------------
412 # Utilities
413 # ---------------------------------------------------------------------------
414
415
416 class TestUtilities:
417 def test_iso_now_format(self) -> None:
418 """iso_now returns an ISO 8601 UTC string."""
419 ts = iso_now()
420 assert "T" in ts
421 assert ts.endswith("Z")
422 assert len(ts) == 20 # "YYYY-MM-DDTHH:MM:SSZ"
423
424 def test_make_run_id_is_unique(self) -> None:
425 """make_run_id returns a different UUID each time."""
426 ids = {make_run_id() for _ in range(100)}
427 assert len(ids) == 100
428
429 def test_make_run_id_is_uuid(self) -> None:
430 """make_run_id returns a parseable UUID v4."""
431 run_id = make_run_id()
432 parsed = uuid.UUID(run_id)
433 assert parsed.version == 4
File History 5 commits
sha256:660fcac1df3ab28f61862e961890bd2ca8b754fa0242079d93ca1e25037ec8a6 chore(tests): add docstring to tests/__init__.py so rc14 tr… Human 26 days ago
sha256:d8316ffae901be06347e16ab55be11868eb519dd16ade3e8aa16a99e662f7e62 baseline: rc14 re-baseline after rc3 store corruption recovery Human patch 26 days ago
sha256:50d413a635f57761c3fce1f70251b957b6423821525bf330747ef4007339222e Merge branch 'dev' into main Human 29 days ago
sha256:fb67fed5a4d3e40de84bdd163de94ef1386570bef1dd1a020a732c8a038962ce Merge branch 'dev' into main Human 48 days ago
sha256:1c4b3e3a9a1f300774c3ee662b572a698d5fd405bf765a71e6011a2e9c3eaaaa feat: Muse — version control for the agent era Human 100 days ago