"""Tests for muse symlog read CLI — Phase 3. Covers SL_23 through SL_35. """ from __future__ import annotations import json import os import pathlib import textwrap import pytest from tests.cli_test_helper import CliRunner runner = CliRunner() # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _invoke(repo: pathlib.Path, args: list[str]): saved = os.getcwd() try: os.chdir(repo) return runner.invoke(None, args) finally: os.chdir(saved) def _init_repo(repo: pathlib.Path) -> None: repo.mkdir(parents=True, exist_ok=True) _invoke(repo, ["init"]) def _write_file(repo: pathlib.Path, rel: str, content: str) -> None: p = repo / rel p.parent.mkdir(parents=True, exist_ok=True) p.write_text(textwrap.dedent(content)) def _commit(repo: pathlib.Path, msg: str) -> str: _invoke(repo, ["code", "add", "."]) result = _invoke(repo, [ "commit", "-m", msg, "--agent-id", "claude-code", "--model-id", "claude-sonnet-4-6", "--author", "claude-code", "--json", ]) data = json.loads(result.stdout) return data["commit_id"] def _make_repo(tmp_path: pathlib.Path) -> pathlib.Path: _init_repo(tmp_path) return tmp_path def _symlog(repo: pathlib.Path, *args: str): return _invoke(repo, ["symlog", *args]) # =========================================================================== # SL_23 — JSON schema: all fields always present # =========================================================================== class TestJsonSchema: def test_all_fields_present_on_empty_log(self, tmp_path: pathlib.Path) -> None: repo = _make_repo(tmp_path) result = _symlog(repo, "src/billing.py::compute_total", "--json") data = json.loads(result.stdout) assert "exit_code" in data assert "duration_ms" in data assert "symbol" in data assert "total" in data assert "limit" in data assert "followed" in data assert "entries" in data assert data["symbol"] == "src/billing.py::compute_total" assert data["total"] == 0 assert data["entries"] == [] assert data["followed"] is False def test_entry_schema_all_fields_present(self, tmp_path: pathlib.Path) -> None: repo = _make_repo(tmp_path) _write_file(repo, "src/billing.py", "def compute_total(items):\n return sum(items)\n") _commit(repo, "add compute_total") result = _symlog(repo, "src/billing.py::compute_total", "--json") data = json.loads(result.stdout) assert data["total"] == 1 entry = data["entries"][0] for field in ("index", "old_content_id", "new_content_id", "commit_id", "author", "timestamp", "operation", "born_from", "from_symbol", "diff"): assert field in entry, f"missing field: {field}" assert entry["index"] == 0 assert entry["born_from"] is None assert entry["from_symbol"] is None assert entry["diff"] is None def test_followed_false_when_no_follow_flag(self, tmp_path: pathlib.Path) -> None: repo = _make_repo(tmp_path) _write_file(repo, "src/billing.py", "def compute_total(items):\n return sum(items)\n") _commit(repo, "add") data = json.loads(_symlog(repo, "src/billing.py::compute_total", "--json").stdout) assert data["followed"] is False def test_followed_true_when_follow_flag(self, tmp_path: pathlib.Path) -> None: repo = _make_repo(tmp_path) _write_file(repo, "src/billing.py", "def compute_total(items):\n return sum(items)\n") _commit(repo, "add") data = json.loads(_symlog(repo, "src/billing.py::compute_total", "--follow", "--json").stdout) assert data["followed"] is True def test_content_ids_are_long_form(self, tmp_path: pathlib.Path) -> None: repo = _make_repo(tmp_path) _write_file(repo, "src/billing.py", "def compute_total(items):\n return sum(items)\n") _commit(repo, "add") data = json.loads(_symlog(repo, "src/billing.py::compute_total", "--json").stdout) entry = data["entries"][0] assert entry["old_content_id"].startswith("sha256:") assert len(entry["old_content_id"]) == 71 # "sha256:" + 64 hex assert entry["new_content_id"].startswith("sha256:") assert len(entry["new_content_id"]) == 71 # =========================================================================== # SL_24 — --limit caps entries after filters # =========================================================================== class TestLimit: def test_limit_caps_entry_list(self, tmp_path: pathlib.Path) -> None: repo = _make_repo(tmp_path) _write_file(repo, "src/billing.py", "def compute_total(items):\n return sum(items)\n") _commit(repo, "add") for i in range(4): _write_file(repo, "src/billing.py", f"def compute_total(items):\n return sum(items) + {i}\n") _commit(repo, f"modify {i}") # 5 entries total; limit 3 data = json.loads(_symlog(repo, "src/billing.py::compute_total", "--limit", "3", "--json").stdout) assert len(data["entries"]) == 3 assert data["total"] == 5 assert data["limit"] == 3 def test_default_limit_is_20(self, tmp_path: pathlib.Path) -> None: repo = _make_repo(tmp_path) data = json.loads(_symlog(repo, "src/billing.py::compute_total", "--json").stdout) assert data["limit"] == 20 # =========================================================================== # SL_25 — --operation filter # =========================================================================== class TestOperationFilter: def test_operation_filter_substring(self, tmp_path: pathlib.Path) -> None: repo = _make_repo(tmp_path) _write_file(repo, "src/billing.py", "def compute_total(items):\n return sum(items)\n") _commit(repo, "initial: add billing") _write_file(repo, "src/billing.py", "def compute_total(items):\n return round(sum(items), 2)\n") _commit(repo, "fix: round total") # Filter to only modified entries (not created) data = json.loads(_symlog( repo, "src/billing.py::compute_total", "--operation", "symbol-modified", "--json" ).stdout) assert data["total"] == 1 assert all("symbol-modified" in e["operation"] for e in data["entries"]) def test_operation_filter_case_insensitive(self, tmp_path: pathlib.Path) -> None: repo = _make_repo(tmp_path) _write_file(repo, "src/billing.py", "def compute_total(items):\n return sum(items)\n") _commit(repo, "add") data = json.loads(_symlog( repo, "src/billing.py::compute_total", "--operation", "SYMBOL-CREATED", "--json" ).stdout) assert data["total"] >= 1 def test_operation_filter_no_match(self, tmp_path: pathlib.Path) -> None: repo = _make_repo(tmp_path) _write_file(repo, "src/billing.py", "def compute_total(items):\n return sum(items)\n") _commit(repo, "add") data = json.loads(_symlog( repo, "src/billing.py::compute_total", "--operation", "xyzzy-nonexistent", "--json" ).stdout) assert data["total"] == 0 assert data["entries"] == [] # =========================================================================== # SL_26 — --author filter # =========================================================================== class TestAuthorFilter: def test_author_filter_matches(self, tmp_path: pathlib.Path) -> None: repo = _make_repo(tmp_path) _write_file(repo, "src/billing.py", "def compute_total(items):\n return sum(items)\n") _commit(repo, "add") data = json.loads(_symlog( repo, "src/billing.py::compute_total", "--author", "claude-code", "--json" ).stdout) assert data["total"] >= 1 def test_author_filter_no_match(self, tmp_path: pathlib.Path) -> None: repo = _make_repo(tmp_path) _write_file(repo, "src/billing.py", "def compute_total(items):\n return sum(items)\n") _commit(repo, "add") data = json.loads(_symlog( repo, "src/billing.py::compute_total", "--author", "nobody-at-all", "--json" ).stdout) assert data["total"] == 0 def test_author_filter_case_insensitive(self, tmp_path: pathlib.Path) -> None: repo = _make_repo(tmp_path) _write_file(repo, "src/billing.py", "def compute_total(items):\n return sum(items)\n") _commit(repo, "add") data = json.loads(_symlog( repo, "src/billing.py::compute_total", "--author", "CLAUDE-CODE", "--json" ).stdout) assert data["total"] >= 1 # =========================================================================== # SL_27 — --since / --until date filters # =========================================================================== class TestDateFilters: def test_since_filters_old_entries(self, tmp_path: pathlib.Path) -> None: repo = _make_repo(tmp_path) _write_file(repo, "src/billing.py", "def compute_total(items):\n return sum(items)\n") _commit(repo, "add") # Far future --since excludes everything data = json.loads(_symlog( repo, "src/billing.py::compute_total", "--since", "2099-01-01", "--json" ).stdout) assert data["total"] == 0 def test_until_filters_future_entries(self, tmp_path: pathlib.Path) -> None: repo = _make_repo(tmp_path) _write_file(repo, "src/billing.py", "def compute_total(items):\n return sum(items)\n") _commit(repo, "add") # Far past --until excludes current entries data = json.loads(_symlog( repo, "src/billing.py::compute_total", "--until", "2000-01-01", "--json" ).stdout) assert data["total"] == 0 def test_since_includes_recent(self, tmp_path: pathlib.Path) -> None: repo = _make_repo(tmp_path) _write_file(repo, "src/billing.py", "def compute_total(items):\n return sum(items)\n") _commit(repo, "add") # Yesterday --since should include today's commit data = json.loads(_symlog( repo, "src/billing.py::compute_total", "--since", "2026-01-01", "--json" ).stdout) assert data["total"] >= 1 def test_since_after_until_is_user_error(self, tmp_path: pathlib.Path) -> None: repo = _make_repo(tmp_path) result = _symlog( repo, "src/billing.py::compute_total", "--since", "2026-12-01", "--until", "2026-01-01" ) assert result.exit_code == 1 def test_invalid_date_format_is_user_error(self, tmp_path: pathlib.Path) -> None: repo = _make_repo(tmp_path) result = _symlog( repo, "src/billing.py::compute_total", "--since", "not-a-date" ) assert result.exit_code == 1 # =========================================================================== # SL_28 — --follow traverses rename chain # =========================================================================== class TestFollow: def test_follow_true_in_json(self, tmp_path: pathlib.Path) -> None: repo = _make_repo(tmp_path) _write_file(repo, "src/billing.py", "def compute_total(items):\n return sum(items)\n") _commit(repo, "add") data = json.loads(_symlog(repo, "src/billing.py::compute_total", "--follow", "--json").stdout) assert data["followed"] is True def test_follow_includes_prior_symbol_entries(self, tmp_path: pathlib.Path) -> None: repo = _make_repo(tmp_path) # Commit 1: create foo _write_file(repo, "src/billing.py", "def foo(x):\n return x\n") _commit(repo, "add foo") # Commit 2: rename foo → bar (same body) _write_file(repo, "src/billing.py", "def bar(x):\n return x\n") _commit(repo, "rename foo to bar") # bar's log with --follow should include foo's created entry data = json.loads(_symlog(repo, "src/billing.py::bar", "--follow", "--json").stdout) ops = [e["operation"] for e in data["entries"]] assert any("symbol-born-from" in op for op in ops) assert any("symbol-created" in op for op in ops) assert len(data["entries"]) >= 2 def test_from_symbol_populated_for_prior_entries(self, tmp_path: pathlib.Path) -> None: repo = _make_repo(tmp_path) _write_file(repo, "src/billing.py", "def foo(x):\n return x\n") _commit(repo, "add foo") _write_file(repo, "src/billing.py", "def bar(x):\n return x\n") _commit(repo, "rename foo to bar") data = json.loads(_symlog(repo, "src/billing.py::bar", "--follow", "--json").stdout) # Find entries that came from foo from_syms = [e["from_symbol"] for e in data["entries"] if e["from_symbol"]] assert len(from_syms) >= 1 assert any("foo" in fs for fs in from_syms) def test_from_symbol_none_for_own_entries(self, tmp_path: pathlib.Path) -> None: repo = _make_repo(tmp_path) _write_file(repo, "src/billing.py", "def foo(x):\n return x\n") _commit(repo, "add foo") _write_file(repo, "src/billing.py", "def bar(x):\n return x\n") _commit(repo, "rename foo to bar") data = json.loads(_symlog(repo, "src/billing.py::bar", "--follow", "--json").stdout) # The born-from entry itself (index 0) belongs to bar — its from_symbol is None assert data["entries"][0]["from_symbol"] is None # =========================================================================== # SL_29 — --diff flag # =========================================================================== class TestDiffFlag: def test_diff_null_for_created_entry(self, tmp_path: pathlib.Path) -> None: repo = _make_repo(tmp_path) _write_file(repo, "src/billing.py", "def compute_total(items):\n return sum(items)\n") _commit(repo, "add") data = json.loads(_symlog(repo, "src/billing.py::compute_total", "--diff", "--json").stdout) # Created entry: old_content_id is null → diff should be None entry = data["entries"][0] assert entry["diff"] is None def test_diff_present_for_modified_entry(self, tmp_path: pathlib.Path) -> None: repo = _make_repo(tmp_path) _write_file(repo, "src/billing.py", "def compute_total(items):\n return sum(items)\n") _commit(repo, "add") _write_file(repo, "src/billing.py", "def compute_total(items):\n return round(sum(items), 2)\n") _commit(repo, "fix: round total") data = json.loads(_symlog(repo, "src/billing.py::compute_total", "--diff", "--json").stdout) modified_entry = data["entries"][0] # newest = modified assert modified_entry["diff"] is not None assert isinstance(modified_entry["diff"], str) def test_diff_absent_without_flag(self, tmp_path: pathlib.Path) -> None: repo = _make_repo(tmp_path) _write_file(repo, "src/billing.py", "def compute_total(items):\n return sum(items)\n") _commit(repo, "add") _write_file(repo, "src/billing.py", "def compute_total(items):\n return round(sum(items), 2)\n") _commit(repo, "fix") data = json.loads(_symlog(repo, "src/billing.py::compute_total", "--json").stdout) for entry in data["entries"]: assert entry["diff"] is None # =========================================================================== # SL_30 — --file mode # =========================================================================== class TestFileMode: def test_file_mode_returns_all_symbols(self, tmp_path: pathlib.Path) -> None: repo = _make_repo(tmp_path) _write_file(repo, "src/billing.py", textwrap.dedent("""\ def compute_total(items): return sum(items) def validate_invoice(inv): return inv is not None """)) _commit(repo, "add billing") data = json.loads(_symlog(repo, "--file", "src/billing.py", "--json").stdout) assert "file" in data assert "symbols" in data assert "count" in data assert data["file"] == "src/billing.py" assert data["count"] == 2 addrs = [s["symbol"] for s in data["symbols"]] assert "src/billing.py::compute_total" in addrs assert "src/billing.py::validate_invoice" in addrs def test_file_mode_each_symbol_has_schema(self, tmp_path: pathlib.Path) -> None: repo = _make_repo(tmp_path) _write_file(repo, "src/billing.py", "def compute_total(items):\n return sum(items)\n") _commit(repo, "add") data = json.loads(_symlog(repo, "--file", "src/billing.py", "--json").stdout) for sym_result in data["symbols"]: for field in ("symbol", "total", "limit", "followed", "entries"): assert field in sym_result def test_file_mode_empty_path(self, tmp_path: pathlib.Path) -> None: repo = _make_repo(tmp_path) data = json.loads(_symlog(repo, "--file", "src/nonexistent.py", "--json").stdout) assert data["count"] == 0 assert data["symbols"] == [] # =========================================================================== # SL_31 — --all mode # =========================================================================== class TestAllMode: def test_all_returns_symbol_list(self, tmp_path: pathlib.Path) -> None: repo = _make_repo(tmp_path) _write_file(repo, "src/billing.py", textwrap.dedent("""\ def compute_total(items): return sum(items) def validate_invoice(inv): return inv is not None """)) _commit(repo, "add billing") data = json.loads(_symlog(repo, "--all", "--json").stdout) assert "symbols" in data assert "count" in data assert data["count"] == 2 assert "src/billing.py::compute_total" in data["symbols"] assert "src/billing.py::validate_invoice" in data["symbols"] def test_all_sorted(self, tmp_path: pathlib.Path) -> None: repo = _make_repo(tmp_path) _write_file(repo, "src/billing.py", textwrap.dedent("""\ def compute_total(items): return sum(items) def validate_invoice(inv): return inv is not None """)) _commit(repo, "add") data = json.loads(_symlog(repo, "--all", "--json").stdout) assert data["symbols"] == sorted(data["symbols"]) def test_all_empty_repo(self, tmp_path: pathlib.Path) -> None: repo = _make_repo(tmp_path) data = json.loads(_symlog(repo, "--all", "--json").stdout) assert data["count"] == 0 assert data["symbols"] == [] # =========================================================================== # SL_32 — exists subcommand # =========================================================================== class TestExists: def test_exists_true_when_entries_present(self, tmp_path: pathlib.Path) -> None: repo = _make_repo(tmp_path) _write_file(repo, "src/billing.py", "def compute_total(items):\n return sum(items)\n") _commit(repo, "add") data = json.loads(_symlog(repo, "exists", "src/billing.py::compute_total", "--json").stdout) assert data["exists"] is True assert data["count"] >= 1 assert data["symbol"] == "src/billing.py::compute_total" def test_exists_false_when_no_entries(self, tmp_path: pathlib.Path) -> None: repo = _make_repo(tmp_path) result = _symlog(repo, "exists", "src/billing.py::compute_total", "--json") data = json.loads(result.stdout) assert data["exists"] is False assert data["count"] == 0 assert result.exit_code == 1 def test_exists_exit_0_when_present(self, tmp_path: pathlib.Path) -> None: repo = _make_repo(tmp_path) _write_file(repo, "src/billing.py", "def compute_total(items):\n return sum(items)\n") _commit(repo, "add") result = _symlog(repo, "exists", "src/billing.py::compute_total") assert result.exit_code == 0 def test_exists_exit_1_when_absent(self, tmp_path: pathlib.Path) -> None: repo = _make_repo(tmp_path) result = _symlog(repo, "exists", "src/billing.py::compute_total") assert result.exit_code == 1 def test_exists_json_exit_code_field(self, tmp_path: pathlib.Path) -> None: repo = _make_repo(tmp_path) result = _symlog(repo, "exists", "src/billing.py::compute_total", "--json") data = json.loads(result.stdout) assert data["exit_code"] == 1 # =========================================================================== # SL_33 — Human text output format # =========================================================================== class TestHumanText: def test_human_text_shows_at_n_prefix(self, tmp_path: pathlib.Path) -> None: repo = _make_repo(tmp_path) _write_file(repo, "src/billing.py", "def compute_total(items):\n return sum(items)\n") _commit(repo, "add") result = _symlog(repo, "src/billing.py::compute_total") assert "@{0}" in result.stdout def test_human_text_created_shows_initial(self, tmp_path: pathlib.Path) -> None: repo = _make_repo(tmp_path) _write_file(repo, "src/billing.py", "def compute_total(items):\n return sum(items)\n") _commit(repo, "add") result = _symlog(repo, "src/billing.py::compute_total") # Created entry: old_content_id is null → shows "initial" assert "initial" in result.stdout def test_human_text_deleted_shows_deleted_label(self, tmp_path: pathlib.Path) -> None: repo = _make_repo(tmp_path) _write_file(repo, "src/billing.py", textwrap.dedent("""\ def compute_total(items): return sum(items) def old_helper(x): return x """)) _commit(repo, "add") _write_file(repo, "src/billing.py", "def compute_total(items):\n return sum(items)\n") _commit(repo, "remove old_helper") result = _symlog(repo, "src/billing.py::old_helper") # Deleted entry: new_content_id is null → shows "deleted" assert "deleted" in result.stdout def test_human_text_shows_operation(self, tmp_path: pathlib.Path) -> None: repo = _make_repo(tmp_path) _write_file(repo, "src/billing.py", "def compute_total(items):\n return sum(items)\n") _commit(repo, "add") result = _symlog(repo, "src/billing.py::compute_total") assert "symbol-created" in result.stdout def test_human_text_header_line(self, tmp_path: pathlib.Path) -> None: repo = _make_repo(tmp_path) _write_file(repo, "src/billing.py", "def compute_total(items):\n return sum(items)\n") _commit(repo, "add") result = _symlog(repo, "src/billing.py::compute_total") assert "Symlog for" in result.stdout assert "src/billing.py::compute_total" in result.stdout # =========================================================================== # SL_34 — Symbol address validation # =========================================================================== class TestAddressValidation: def test_missing_separator_is_user_error(self, tmp_path: pathlib.Path) -> None: repo = _make_repo(tmp_path) result = _symlog(repo, "src/billing.py") assert result.exit_code == 1 def test_empty_symbol_name_is_user_error(self, tmp_path: pathlib.Path) -> None: repo = _make_repo(tmp_path) result = _symlog(repo, "src/billing.py::") assert result.exit_code == 1 def test_path_traversal_is_user_error(self, tmp_path: pathlib.Path) -> None: repo = _make_repo(tmp_path) result = _symlog(repo, "../../etc/passwd::compute_total") assert result.exit_code == 1 def test_valid_address_does_not_error(self, tmp_path: pathlib.Path) -> None: repo = _make_repo(tmp_path) result = _symlog(repo, "src/billing.py::compute_total", "--json") # No log yet, but valid address — should succeed with empty entries assert result.exit_code == 0 data = json.loads(result.stdout) assert data["total"] == 0 # =========================================================================== # SL_35 — Integration: 4-commit history with combined filters + --follow # =========================================================================== class TestIntegrationFiltersAndFollow: def test_four_commit_filter_combination(self, tmp_path: pathlib.Path) -> None: """SL_35: 4-commit sequence; filters applied in combination.""" repo = _make_repo(tmp_path) # Commit 1: create compute_total _write_file(repo, "src/billing.py", "def compute_total(items):\n return sum(items)\n") c1 = _commit(repo, "initial: add billing") # Commit 2: modify _write_file(repo, "src/billing.py", "def compute_total(items):\n return round(sum(items), 2)\n") c2 = _commit(repo, "fix: round total") # Commit 3: modify again _write_file(repo, "src/billing.py", "def compute_total(items):\n return max(0, round(sum(items), 2))\n") c3 = _commit(repo, "fix: clamp at zero") # Commit 4: modify once more _write_file(repo, "src/billing.py", "def compute_total(items):\n return max(0.0, round(sum(items), 2))\n") c4 = _commit(repo, "fix: use float zero") # 4 total entries: 1 created + 3 modified data = json.loads(_symlog(repo, "src/billing.py::compute_total", "--json").stdout) assert data["total"] == 4 # Filter to modified only: should be 3 data_mod = json.loads(_symlog( repo, "src/billing.py::compute_total", "--operation", "symbol-modified", "--json" ).stdout) assert data_mod["total"] == 3 # Filter to modified + limit 2: should return 2 newest data_lim = json.loads(_symlog( repo, "src/billing.py::compute_total", "--operation", "symbol-modified", "--limit", "2", "--json" ).stdout) assert len(data_lim["entries"]) == 2 assert data_lim["total"] == 3 # total after filter, before limit # Filter by author data_auth = json.loads(_symlog( repo, "src/billing.py::compute_total", "--author", "claude-code", "--json" ).stdout) assert data_auth["total"] == 4 # Commit IDs in entries match the actual commits all_data = json.loads(_symlog(repo, "src/billing.py::compute_total", "--json").stdout) commit_ids_in_log = [e["commit_id"] for e in all_data["entries"]] assert c4 in commit_ids_in_log assert c1 in commit_ids_in_log def test_follow_stitches_two_symbols_newest_first(self, tmp_path: pathlib.Path) -> None: """SL_35: --follow merges entries from two symbol addresses newest-first.""" repo = _make_repo(tmp_path) # Commit 1: create foo _write_file(repo, "src/billing.py", "def foo(x):\n return x\n") _commit(repo, "add foo") # Commit 2: rename foo → bar _write_file(repo, "src/billing.py", "def bar(x):\n return x\n") _commit(repo, "rename foo to bar") # Commit 3: modify bar _write_file(repo, "src/billing.py", "def bar(x):\n return x * 2\n") _commit(repo, "fix: bar doubles") # bar's log without follow: born-from entry + modified entry = 2 entries data_no_follow = json.loads(_symlog(repo, "src/billing.py::bar", "--json").stdout) assert data_no_follow["total"] == 2 # bar's log with follow: 2 own + 1 from foo (created) = 3 entries data_follow = json.loads(_symlog(repo, "src/billing.py::bar", "--follow", "--json").stdout) assert data_follow["followed"] is True assert data_follow["total"] >= 3 # Entries are newest-first: index 0 is the most recent entries = data_follow["entries"] assert entries[0]["operation"].startswith("symbol-modified") # The tail (oldest) entry should be from foo's created event tail = entries[-1] assert tail["operation"].startswith("symbol-created") assert tail["from_symbol"] is not None assert "foo" in tail["from_symbol"]