gabriel / muse public

test_cmd_symlog_read.py file-level

at sha256:a · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 💥 blast risk
sha256:6 chore(timeline): remove unused RationalRate import in entity.py · · Jul 10, 2026
1 """Tests for muse symlog read CLI — Phase 3.
2
3 Covers SL_23 through SL_35.
4 """
5
6 from __future__ import annotations
7
8 import json
9 import os
10 import pathlib
11 import textwrap
12
13 import pytest
14
15 from tests.cli_test_helper import CliRunner
16
17 runner = CliRunner()
18
19
20 # ---------------------------------------------------------------------------
21 # Helpers
22 # ---------------------------------------------------------------------------
23
24
25 def _invoke(repo: pathlib.Path, args: list[str]):
26 saved = os.getcwd()
27 try:
28 os.chdir(repo)
29 return runner.invoke(None, args)
30 finally:
31 os.chdir(saved)
32
33
34 def _init_repo(repo: pathlib.Path) -> None:
35 repo.mkdir(parents=True, exist_ok=True)
36 _invoke(repo, ["init"])
37
38
39 def _write_file(repo: pathlib.Path, rel: str, content: str) -> None:
40 p = repo / rel
41 p.parent.mkdir(parents=True, exist_ok=True)
42 p.write_text(textwrap.dedent(content))
43
44
45 def _commit(repo: pathlib.Path, msg: str) -> str:
46 _invoke(repo, ["code", "add", "."])
47 result = _invoke(repo, [
48 "commit", "-m", msg,
49 "--agent-id", "claude-code",
50 "--model-id", "claude-sonnet-4-6",
51 "--author", "claude-code",
52 "--json",
53 ])
54 data = json.loads(result.stdout)
55 return data["commit_id"]
56
57
58 def _make_repo(tmp_path: pathlib.Path) -> pathlib.Path:
59 _init_repo(tmp_path)
60 return tmp_path
61
62
63 def _symlog(repo: pathlib.Path, *args: str):
64 return _invoke(repo, ["symlog", *args])
65
66
67 # ===========================================================================
68 # SL_23 — JSON schema: all fields always present
69 # ===========================================================================
70
71
72 class TestJsonSchema:
73 def test_all_fields_present_on_empty_log(self, tmp_path: pathlib.Path) -> None:
74 repo = _make_repo(tmp_path)
75 result = _symlog(repo, "src/billing.py::compute_total", "--json")
76 data = json.loads(result.stdout)
77 assert "exit_code" in data
78 assert "duration_ms" in data
79 assert "symbol" in data
80 assert "total" in data
81 assert "limit" in data
82 assert "followed" in data
83 assert "entries" in data
84 assert data["symbol"] == "src/billing.py::compute_total"
85 assert data["total"] == 0
86 assert data["entries"] == []
87 assert data["followed"] is False
88
89 def test_entry_schema_all_fields_present(self, tmp_path: pathlib.Path) -> None:
90 repo = _make_repo(tmp_path)
91 _write_file(repo, "src/billing.py", "def compute_total(items):\n return sum(items)\n")
92 _commit(repo, "add compute_total")
93
94 result = _symlog(repo, "src/billing.py::compute_total", "--json")
95 data = json.loads(result.stdout)
96 assert data["total"] == 1
97 entry = data["entries"][0]
98
99 for field in ("index", "old_content_id", "new_content_id", "commit_id",
100 "author", "timestamp", "operation", "born_from", "from_symbol", "diff"):
101 assert field in entry, f"missing field: {field}"
102
103 assert entry["index"] == 0
104 assert entry["born_from"] is None
105 assert entry["from_symbol"] is None
106 assert entry["diff"] is None
107
108 def test_followed_false_when_no_follow_flag(self, tmp_path: pathlib.Path) -> None:
109 repo = _make_repo(tmp_path)
110 _write_file(repo, "src/billing.py", "def compute_total(items):\n return sum(items)\n")
111 _commit(repo, "add")
112 data = json.loads(_symlog(repo, "src/billing.py::compute_total", "--json").stdout)
113 assert data["followed"] is False
114
115 def test_followed_true_when_follow_flag(self, tmp_path: pathlib.Path) -> None:
116 repo = _make_repo(tmp_path)
117 _write_file(repo, "src/billing.py", "def compute_total(items):\n return sum(items)\n")
118 _commit(repo, "add")
119 data = json.loads(_symlog(repo, "src/billing.py::compute_total", "--follow", "--json").stdout)
120 assert data["followed"] is True
121
122 def test_content_ids_are_long_form(self, tmp_path: pathlib.Path) -> None:
123 repo = _make_repo(tmp_path)
124 _write_file(repo, "src/billing.py", "def compute_total(items):\n return sum(items)\n")
125 _commit(repo, "add")
126 data = json.loads(_symlog(repo, "src/billing.py::compute_total", "--json").stdout)
127 entry = data["entries"][0]
128 assert entry["old_content_id"].startswith("sha256:")
129 assert len(entry["old_content_id"]) == 71 # "sha256:" + 64 hex
130 assert entry["new_content_id"].startswith("sha256:")
131 assert len(entry["new_content_id"]) == 71
132
133
134 # ===========================================================================
135 # SL_24 — --limit caps entries after filters
136 # ===========================================================================
137
138
139 class TestLimit:
140 def test_limit_caps_entry_list(self, tmp_path: pathlib.Path) -> None:
141 repo = _make_repo(tmp_path)
142 _write_file(repo, "src/billing.py", "def compute_total(items):\n return sum(items)\n")
143 _commit(repo, "add")
144 for i in range(4):
145 _write_file(repo, "src/billing.py", f"def compute_total(items):\n return sum(items) + {i}\n")
146 _commit(repo, f"modify {i}")
147
148 # 5 entries total; limit 3
149 data = json.loads(_symlog(repo, "src/billing.py::compute_total", "--limit", "3", "--json").stdout)
150 assert len(data["entries"]) == 3
151 assert data["total"] == 5
152 assert data["limit"] == 3
153
154 def test_default_limit_is_20(self, tmp_path: pathlib.Path) -> None:
155 repo = _make_repo(tmp_path)
156 data = json.loads(_symlog(repo, "src/billing.py::compute_total", "--json").stdout)
157 assert data["limit"] == 20
158
159
160 # ===========================================================================
161 # SL_25 — --operation filter
162 # ===========================================================================
163
164
165 class TestOperationFilter:
166 def test_operation_filter_substring(self, tmp_path: pathlib.Path) -> None:
167 repo = _make_repo(tmp_path)
168 _write_file(repo, "src/billing.py", "def compute_total(items):\n return sum(items)\n")
169 _commit(repo, "initial: add billing")
170 _write_file(repo, "src/billing.py", "def compute_total(items):\n return round(sum(items), 2)\n")
171 _commit(repo, "fix: round total")
172
173 # Filter to only modified entries (not created)
174 data = json.loads(_symlog(
175 repo, "src/billing.py::compute_total",
176 "--operation", "symbol-modified", "--json"
177 ).stdout)
178 assert data["total"] == 1
179 assert all("symbol-modified" in e["operation"] for e in data["entries"])
180
181 def test_operation_filter_case_insensitive(self, tmp_path: pathlib.Path) -> None:
182 repo = _make_repo(tmp_path)
183 _write_file(repo, "src/billing.py", "def compute_total(items):\n return sum(items)\n")
184 _commit(repo, "add")
185
186 data = json.loads(_symlog(
187 repo, "src/billing.py::compute_total",
188 "--operation", "SYMBOL-CREATED", "--json"
189 ).stdout)
190 assert data["total"] >= 1
191
192 def test_operation_filter_no_match(self, tmp_path: pathlib.Path) -> None:
193 repo = _make_repo(tmp_path)
194 _write_file(repo, "src/billing.py", "def compute_total(items):\n return sum(items)\n")
195 _commit(repo, "add")
196
197 data = json.loads(_symlog(
198 repo, "src/billing.py::compute_total",
199 "--operation", "xyzzy-nonexistent", "--json"
200 ).stdout)
201 assert data["total"] == 0
202 assert data["entries"] == []
203
204
205 # ===========================================================================
206 # SL_26 — --author filter
207 # ===========================================================================
208
209
210 class TestAuthorFilter:
211 def test_author_filter_matches(self, tmp_path: pathlib.Path) -> None:
212 repo = _make_repo(tmp_path)
213 _write_file(repo, "src/billing.py", "def compute_total(items):\n return sum(items)\n")
214 _commit(repo, "add")
215
216 data = json.loads(_symlog(
217 repo, "src/billing.py::compute_total",
218 "--author", "claude-code", "--json"
219 ).stdout)
220 assert data["total"] >= 1
221
222 def test_author_filter_no_match(self, tmp_path: pathlib.Path) -> None:
223 repo = _make_repo(tmp_path)
224 _write_file(repo, "src/billing.py", "def compute_total(items):\n return sum(items)\n")
225 _commit(repo, "add")
226
227 data = json.loads(_symlog(
228 repo, "src/billing.py::compute_total",
229 "--author", "nobody-at-all", "--json"
230 ).stdout)
231 assert data["total"] == 0
232
233 def test_author_filter_case_insensitive(self, tmp_path: pathlib.Path) -> None:
234 repo = _make_repo(tmp_path)
235 _write_file(repo, "src/billing.py", "def compute_total(items):\n return sum(items)\n")
236 _commit(repo, "add")
237
238 data = json.loads(_symlog(
239 repo, "src/billing.py::compute_total",
240 "--author", "CLAUDE-CODE", "--json"
241 ).stdout)
242 assert data["total"] >= 1
243
244
245 # ===========================================================================
246 # SL_27 — --since / --until date filters
247 # ===========================================================================
248
249
250 class TestDateFilters:
251 def test_since_filters_old_entries(self, tmp_path: pathlib.Path) -> None:
252 repo = _make_repo(tmp_path)
253 _write_file(repo, "src/billing.py", "def compute_total(items):\n return sum(items)\n")
254 _commit(repo, "add")
255
256 # Far future --since excludes everything
257 data = json.loads(_symlog(
258 repo, "src/billing.py::compute_total",
259 "--since", "2099-01-01", "--json"
260 ).stdout)
261 assert data["total"] == 0
262
263 def test_until_filters_future_entries(self, tmp_path: pathlib.Path) -> None:
264 repo = _make_repo(tmp_path)
265 _write_file(repo, "src/billing.py", "def compute_total(items):\n return sum(items)\n")
266 _commit(repo, "add")
267
268 # Far past --until excludes current entries
269 data = json.loads(_symlog(
270 repo, "src/billing.py::compute_total",
271 "--until", "2000-01-01", "--json"
272 ).stdout)
273 assert data["total"] == 0
274
275 def test_since_includes_recent(self, tmp_path: pathlib.Path) -> None:
276 repo = _make_repo(tmp_path)
277 _write_file(repo, "src/billing.py", "def compute_total(items):\n return sum(items)\n")
278 _commit(repo, "add")
279
280 # Yesterday --since should include today's commit
281 data = json.loads(_symlog(
282 repo, "src/billing.py::compute_total",
283 "--since", "2026-01-01", "--json"
284 ).stdout)
285 assert data["total"] >= 1
286
287 def test_since_after_until_is_user_error(self, tmp_path: pathlib.Path) -> None:
288 repo = _make_repo(tmp_path)
289 result = _symlog(
290 repo, "src/billing.py::compute_total",
291 "--since", "2026-12-01", "--until", "2026-01-01"
292 )
293 assert result.exit_code == 1
294
295 def test_invalid_date_format_is_user_error(self, tmp_path: pathlib.Path) -> None:
296 repo = _make_repo(tmp_path)
297 result = _symlog(
298 repo, "src/billing.py::compute_total",
299 "--since", "not-a-date"
300 )
301 assert result.exit_code == 1
302
303
304 # ===========================================================================
305 # SL_28 — --follow traverses rename chain
306 # ===========================================================================
307
308
309 class TestFollow:
310 def test_follow_true_in_json(self, tmp_path: pathlib.Path) -> None:
311 repo = _make_repo(tmp_path)
312 _write_file(repo, "src/billing.py", "def compute_total(items):\n return sum(items)\n")
313 _commit(repo, "add")
314 data = json.loads(_symlog(repo, "src/billing.py::compute_total", "--follow", "--json").stdout)
315 assert data["followed"] is True
316
317 def test_follow_includes_prior_symbol_entries(self, tmp_path: pathlib.Path) -> None:
318 repo = _make_repo(tmp_path)
319 # Commit 1: create foo
320 _write_file(repo, "src/billing.py", "def foo(x):\n return x\n")
321 _commit(repo, "add foo")
322
323 # Commit 2: rename foo → bar (same body)
324 _write_file(repo, "src/billing.py", "def bar(x):\n return x\n")
325 _commit(repo, "rename foo to bar")
326
327 # bar's log with --follow should include foo's created entry
328 data = json.loads(_symlog(repo, "src/billing.py::bar", "--follow", "--json").stdout)
329 ops = [e["operation"] for e in data["entries"]]
330 assert any("symbol-born-from" in op for op in ops)
331 assert any("symbol-created" in op for op in ops)
332 assert len(data["entries"]) >= 2
333
334 def test_from_symbol_populated_for_prior_entries(self, tmp_path: pathlib.Path) -> None:
335 repo = _make_repo(tmp_path)
336 _write_file(repo, "src/billing.py", "def foo(x):\n return x\n")
337 _commit(repo, "add foo")
338 _write_file(repo, "src/billing.py", "def bar(x):\n return x\n")
339 _commit(repo, "rename foo to bar")
340
341 data = json.loads(_symlog(repo, "src/billing.py::bar", "--follow", "--json").stdout)
342 # Find entries that came from foo
343 from_syms = [e["from_symbol"] for e in data["entries"] if e["from_symbol"]]
344 assert len(from_syms) >= 1
345 assert any("foo" in fs for fs in from_syms)
346
347 def test_from_symbol_none_for_own_entries(self, tmp_path: pathlib.Path) -> None:
348 repo = _make_repo(tmp_path)
349 _write_file(repo, "src/billing.py", "def foo(x):\n return x\n")
350 _commit(repo, "add foo")
351 _write_file(repo, "src/billing.py", "def bar(x):\n return x\n")
352 _commit(repo, "rename foo to bar")
353
354 data = json.loads(_symlog(repo, "src/billing.py::bar", "--follow", "--json").stdout)
355 # The born-from entry itself (index 0) belongs to bar — its from_symbol is None
356 assert data["entries"][0]["from_symbol"] is None
357
358
359 # ===========================================================================
360 # SL_29 — --diff flag
361 # ===========================================================================
362
363
364 class TestDiffFlag:
365 def test_diff_null_for_created_entry(self, tmp_path: pathlib.Path) -> None:
366 repo = _make_repo(tmp_path)
367 _write_file(repo, "src/billing.py", "def compute_total(items):\n return sum(items)\n")
368 _commit(repo, "add")
369
370 data = json.loads(_symlog(repo, "src/billing.py::compute_total", "--diff", "--json").stdout)
371 # Created entry: old_content_id is null → diff should be None
372 entry = data["entries"][0]
373 assert entry["diff"] is None
374
375 def test_diff_present_for_modified_entry(self, tmp_path: pathlib.Path) -> None:
376 repo = _make_repo(tmp_path)
377 _write_file(repo, "src/billing.py", "def compute_total(items):\n return sum(items)\n")
378 _commit(repo, "add")
379 _write_file(repo, "src/billing.py", "def compute_total(items):\n return round(sum(items), 2)\n")
380 _commit(repo, "fix: round total")
381
382 data = json.loads(_symlog(repo, "src/billing.py::compute_total", "--diff", "--json").stdout)
383 modified_entry = data["entries"][0] # newest = modified
384 assert modified_entry["diff"] is not None
385 assert isinstance(modified_entry["diff"], str)
386
387 def test_diff_absent_without_flag(self, tmp_path: pathlib.Path) -> None:
388 repo = _make_repo(tmp_path)
389 _write_file(repo, "src/billing.py", "def compute_total(items):\n return sum(items)\n")
390 _commit(repo, "add")
391 _write_file(repo, "src/billing.py", "def compute_total(items):\n return round(sum(items), 2)\n")
392 _commit(repo, "fix")
393
394 data = json.loads(_symlog(repo, "src/billing.py::compute_total", "--json").stdout)
395 for entry in data["entries"]:
396 assert entry["diff"] is None
397
398
399 # ===========================================================================
400 # SL_30 — --file mode
401 # ===========================================================================
402
403
404 class TestFileMode:
405 def test_file_mode_returns_all_symbols(self, tmp_path: pathlib.Path) -> None:
406 repo = _make_repo(tmp_path)
407 _write_file(repo, "src/billing.py", textwrap.dedent("""\
408 def compute_total(items):
409 return sum(items)
410
411 def validate_invoice(inv):
412 return inv is not None
413 """))
414 _commit(repo, "add billing")
415
416 data = json.loads(_symlog(repo, "--file", "src/billing.py", "--json").stdout)
417 assert "file" in data
418 assert "symbols" in data
419 assert "count" in data
420 assert data["file"] == "src/billing.py"
421 assert data["count"] == 2
422 addrs = [s["symbol"] for s in data["symbols"]]
423 assert "src/billing.py::compute_total" in addrs
424 assert "src/billing.py::validate_invoice" in addrs
425
426 def test_file_mode_each_symbol_has_schema(self, tmp_path: pathlib.Path) -> None:
427 repo = _make_repo(tmp_path)
428 _write_file(repo, "src/billing.py", "def compute_total(items):\n return sum(items)\n")
429 _commit(repo, "add")
430
431 data = json.loads(_symlog(repo, "--file", "src/billing.py", "--json").stdout)
432 for sym_result in data["symbols"]:
433 for field in ("symbol", "total", "limit", "followed", "entries"):
434 assert field in sym_result
435
436 def test_file_mode_empty_path(self, tmp_path: pathlib.Path) -> None:
437 repo = _make_repo(tmp_path)
438 data = json.loads(_symlog(repo, "--file", "src/nonexistent.py", "--json").stdout)
439 assert data["count"] == 0
440 assert data["symbols"] == []
441
442
443 # ===========================================================================
444 # SL_31 — --all mode
445 # ===========================================================================
446
447
448 class TestAllMode:
449 def test_all_returns_symbol_list(self, tmp_path: pathlib.Path) -> None:
450 repo = _make_repo(tmp_path)
451 _write_file(repo, "src/billing.py", textwrap.dedent("""\
452 def compute_total(items):
453 return sum(items)
454
455 def validate_invoice(inv):
456 return inv is not None
457 """))
458 _commit(repo, "add billing")
459
460 data = json.loads(_symlog(repo, "--all", "--json").stdout)
461 assert "symbols" in data
462 assert "count" in data
463 assert data["count"] == 2
464 assert "src/billing.py::compute_total" in data["symbols"]
465 assert "src/billing.py::validate_invoice" in data["symbols"]
466
467 def test_all_sorted(self, tmp_path: pathlib.Path) -> None:
468 repo = _make_repo(tmp_path)
469 _write_file(repo, "src/billing.py", textwrap.dedent("""\
470 def compute_total(items):
471 return sum(items)
472
473 def validate_invoice(inv):
474 return inv is not None
475 """))
476 _commit(repo, "add")
477
478 data = json.loads(_symlog(repo, "--all", "--json").stdout)
479 assert data["symbols"] == sorted(data["symbols"])
480
481 def test_all_empty_repo(self, tmp_path: pathlib.Path) -> None:
482 repo = _make_repo(tmp_path)
483 data = json.loads(_symlog(repo, "--all", "--json").stdout)
484 assert data["count"] == 0
485 assert data["symbols"] == []
486
487
488 # ===========================================================================
489 # SL_32 — exists subcommand
490 # ===========================================================================
491
492
493 class TestExists:
494 def test_exists_true_when_entries_present(self, tmp_path: pathlib.Path) -> None:
495 repo = _make_repo(tmp_path)
496 _write_file(repo, "src/billing.py", "def compute_total(items):\n return sum(items)\n")
497 _commit(repo, "add")
498
499 data = json.loads(_symlog(repo, "exists", "src/billing.py::compute_total", "--json").stdout)
500 assert data["exists"] is True
501 assert data["count"] >= 1
502 assert data["symbol"] == "src/billing.py::compute_total"
503
504 def test_exists_false_when_no_entries(self, tmp_path: pathlib.Path) -> None:
505 repo = _make_repo(tmp_path)
506 result = _symlog(repo, "exists", "src/billing.py::compute_total", "--json")
507 data = json.loads(result.stdout)
508 assert data["exists"] is False
509 assert data["count"] == 0
510 assert result.exit_code == 1
511
512 def test_exists_exit_0_when_present(self, tmp_path: pathlib.Path) -> None:
513 repo = _make_repo(tmp_path)
514 _write_file(repo, "src/billing.py", "def compute_total(items):\n return sum(items)\n")
515 _commit(repo, "add")
516 result = _symlog(repo, "exists", "src/billing.py::compute_total")
517 assert result.exit_code == 0
518
519 def test_exists_exit_1_when_absent(self, tmp_path: pathlib.Path) -> None:
520 repo = _make_repo(tmp_path)
521 result = _symlog(repo, "exists", "src/billing.py::compute_total")
522 assert result.exit_code == 1
523
524 def test_exists_json_exit_code_field(self, tmp_path: pathlib.Path) -> None:
525 repo = _make_repo(tmp_path)
526 result = _symlog(repo, "exists", "src/billing.py::compute_total", "--json")
527 data = json.loads(result.stdout)
528 assert data["exit_code"] == 1
529
530
531 # ===========================================================================
532 # SL_33 — Human text output format
533 # ===========================================================================
534
535
536 class TestHumanText:
537 def test_human_text_shows_at_n_prefix(self, tmp_path: pathlib.Path) -> None:
538 repo = _make_repo(tmp_path)
539 _write_file(repo, "src/billing.py", "def compute_total(items):\n return sum(items)\n")
540 _commit(repo, "add")
541 result = _symlog(repo, "src/billing.py::compute_total")
542 assert "@{0}" in result.stdout
543
544 def test_human_text_created_shows_initial(self, tmp_path: pathlib.Path) -> None:
545 repo = _make_repo(tmp_path)
546 _write_file(repo, "src/billing.py", "def compute_total(items):\n return sum(items)\n")
547 _commit(repo, "add")
548 result = _symlog(repo, "src/billing.py::compute_total")
549 # Created entry: old_content_id is null → shows "initial"
550 assert "initial" in result.stdout
551
552 def test_human_text_deleted_shows_deleted_label(self, tmp_path: pathlib.Path) -> None:
553 repo = _make_repo(tmp_path)
554 _write_file(repo, "src/billing.py", textwrap.dedent("""\
555 def compute_total(items):
556 return sum(items)
557
558 def old_helper(x):
559 return x
560 """))
561 _commit(repo, "add")
562 _write_file(repo, "src/billing.py", "def compute_total(items):\n return sum(items)\n")
563 _commit(repo, "remove old_helper")
564 result = _symlog(repo, "src/billing.py::old_helper")
565 # Deleted entry: new_content_id is null → shows "deleted"
566 assert "deleted" in result.stdout
567
568 def test_human_text_shows_operation(self, tmp_path: pathlib.Path) -> None:
569 repo = _make_repo(tmp_path)
570 _write_file(repo, "src/billing.py", "def compute_total(items):\n return sum(items)\n")
571 _commit(repo, "add")
572 result = _symlog(repo, "src/billing.py::compute_total")
573 assert "symbol-created" in result.stdout
574
575 def test_human_text_header_line(self, tmp_path: pathlib.Path) -> None:
576 repo = _make_repo(tmp_path)
577 _write_file(repo, "src/billing.py", "def compute_total(items):\n return sum(items)\n")
578 _commit(repo, "add")
579 result = _symlog(repo, "src/billing.py::compute_total")
580 assert "Symlog for" in result.stdout
581 assert "src/billing.py::compute_total" in result.stdout
582
583
584 # ===========================================================================
585 # SL_34 — Symbol address validation
586 # ===========================================================================
587
588
589 class TestAddressValidation:
590 def test_missing_separator_is_user_error(self, tmp_path: pathlib.Path) -> None:
591 repo = _make_repo(tmp_path)
592 result = _symlog(repo, "src/billing.py")
593 assert result.exit_code == 1
594
595 def test_empty_symbol_name_is_user_error(self, tmp_path: pathlib.Path) -> None:
596 repo = _make_repo(tmp_path)
597 result = _symlog(repo, "src/billing.py::")
598 assert result.exit_code == 1
599
600 def test_path_traversal_is_user_error(self, tmp_path: pathlib.Path) -> None:
601 repo = _make_repo(tmp_path)
602 result = _symlog(repo, "../../etc/passwd::compute_total")
603 assert result.exit_code == 1
604
605 def test_valid_address_does_not_error(self, tmp_path: pathlib.Path) -> None:
606 repo = _make_repo(tmp_path)
607 result = _symlog(repo, "src/billing.py::compute_total", "--json")
608 # No log yet, but valid address — should succeed with empty entries
609 assert result.exit_code == 0
610 data = json.loads(result.stdout)
611 assert data["total"] == 0
612
613
614 # ===========================================================================
615 # SL_35 — Integration: 4-commit history with combined filters + --follow
616 # ===========================================================================
617
618
619 class TestIntegrationFiltersAndFollow:
620 def test_four_commit_filter_combination(self, tmp_path: pathlib.Path) -> None:
621 """SL_35: 4-commit sequence; filters applied in combination."""
622 repo = _make_repo(tmp_path)
623
624 # Commit 1: create compute_total
625 _write_file(repo, "src/billing.py", "def compute_total(items):\n return sum(items)\n")
626 c1 = _commit(repo, "initial: add billing")
627
628 # Commit 2: modify
629 _write_file(repo, "src/billing.py", "def compute_total(items):\n return round(sum(items), 2)\n")
630 c2 = _commit(repo, "fix: round total")
631
632 # Commit 3: modify again
633 _write_file(repo, "src/billing.py", "def compute_total(items):\n return max(0, round(sum(items), 2))\n")
634 c3 = _commit(repo, "fix: clamp at zero")
635
636 # Commit 4: modify once more
637 _write_file(repo, "src/billing.py", "def compute_total(items):\n return max(0.0, round(sum(items), 2))\n")
638 c4 = _commit(repo, "fix: use float zero")
639
640 # 4 total entries: 1 created + 3 modified
641 data = json.loads(_symlog(repo, "src/billing.py::compute_total", "--json").stdout)
642 assert data["total"] == 4
643
644 # Filter to modified only: should be 3
645 data_mod = json.loads(_symlog(
646 repo, "src/billing.py::compute_total",
647 "--operation", "symbol-modified", "--json"
648 ).stdout)
649 assert data_mod["total"] == 3
650
651 # Filter to modified + limit 2: should return 2 newest
652 data_lim = json.loads(_symlog(
653 repo, "src/billing.py::compute_total",
654 "--operation", "symbol-modified", "--limit", "2", "--json"
655 ).stdout)
656 assert len(data_lim["entries"]) == 2
657 assert data_lim["total"] == 3 # total after filter, before limit
658
659 # Filter by author
660 data_auth = json.loads(_symlog(
661 repo, "src/billing.py::compute_total",
662 "--author", "claude-code", "--json"
663 ).stdout)
664 assert data_auth["total"] == 4
665
666 # Commit IDs in entries match the actual commits
667 all_data = json.loads(_symlog(repo, "src/billing.py::compute_total", "--json").stdout)
668 commit_ids_in_log = [e["commit_id"] for e in all_data["entries"]]
669 assert c4 in commit_ids_in_log
670 assert c1 in commit_ids_in_log
671
672 def test_follow_stitches_two_symbols_newest_first(self, tmp_path: pathlib.Path) -> None:
673 """SL_35: --follow merges entries from two symbol addresses newest-first."""
674 repo = _make_repo(tmp_path)
675
676 # Commit 1: create foo
677 _write_file(repo, "src/billing.py", "def foo(x):\n return x\n")
678 _commit(repo, "add foo")
679
680 # Commit 2: rename foo → bar
681 _write_file(repo, "src/billing.py", "def bar(x):\n return x\n")
682 _commit(repo, "rename foo to bar")
683
684 # Commit 3: modify bar
685 _write_file(repo, "src/billing.py", "def bar(x):\n return x * 2\n")
686 _commit(repo, "fix: bar doubles")
687
688 # bar's log without follow: born-from entry + modified entry = 2 entries
689 data_no_follow = json.loads(_symlog(repo, "src/billing.py::bar", "--json").stdout)
690 assert data_no_follow["total"] == 2
691
692 # bar's log with follow: 2 own + 1 from foo (created) = 3 entries
693 data_follow = json.loads(_symlog(repo, "src/billing.py::bar", "--follow", "--json").stdout)
694 assert data_follow["followed"] is True
695 assert data_follow["total"] >= 3
696
697 # Entries are newest-first: index 0 is the most recent
698 entries = data_follow["entries"]
699 assert entries[0]["operation"].startswith("symbol-modified")
700
701 # The tail (oldest) entry should be from foo's created event
702 tail = entries[-1]
703 assert tail["operation"].startswith("symbol-created")
704 assert tail["from_symbol"] is not None
705 assert "foo" in tail["from_symbol"]