"""Supercharge tests for ``muse code narrative``. Coverage gaps addressed ----------------------- - ``-j`` alias for ``--json`` - ``exit_code`` field in JSON envelope - ``duration_ms`` field in JSON envelope - ``sig_changes`` / ``renames`` counts verified in JSON - ``truncated`` is False for small repos - ``kind`` is correct in JSON - ``last_impl_date`` / ``last_impl_commit`` present and non-empty when impl exists - JSON is a single line (machine-parseable) - TypedDict exports: ``_NarrativeJson`` and ``_EventRecord`` importable, match output - Unit: ``_classify_op`` all branches - Unit: ``_sanitise_msg`` truncation and control-char stripping - Unit: ``_format_date`` / ``_format_date_long`` - Unit: ``_days_ago`` buckets - Unit: ``_relative_to`` buckets - Unit: ``_extract_rename`` patterns - Unit: ``_event_detail`` - ``--show-source`` does not crash with ``--json`` mode """ from __future__ import annotations from collections.abc import Mapping import datetime import json import pathlib import textwrap import pytest from tests.cli_test_helper import CliRunner cli = None # CliRunner ignores this argument; see cli_test_helper.py runner = CliRunner() # --------------------------------------------------------------------------- # Base repo fixture # --------------------------------------------------------------------------- @pytest.fixture() def repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path: monkeypatch.chdir(tmp_path) monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path)) r = runner.invoke(cli, ["init", "--domain", "code"]) assert r.exit_code == 0, r.output return tmp_path # --------------------------------------------------------------------------- # Shared fixture — minimal repo with a symbol that has a rich history # --------------------------------------------------------------------------- @pytest.fixture() def narrative_repo(repo: pathlib.Path) -> pathlib.Path: """Repo with billing.py::compute_total across four commits. Commit 1 — seed (readme only) Commit 2 — create billing.py with compute_total Commit 3 — body rewrite (impl) Commit 4 — signature change """ (repo / "readme.txt").write_text("seed\n") runner.invoke(cli, ["code", "add", "readme.txt"]) r = runner.invoke(cli, ["commit", "-m", "chore: seed"]) assert r.exit_code == 0, r.output (repo / "billing.py").write_text(textwrap.dedent("""\ def compute_total(items): total = 0 for item in items: total += item["price"] return total """)) runner.invoke(cli, ["code", "add", "billing.py"]) r = runner.invoke(cli, ["commit", "-m", "feat: add compute_total"]) assert r.exit_code == 0, r.output (repo / "billing.py").write_text(textwrap.dedent("""\ def compute_total(items): return sum(i["price"] for i in items) """)) runner.invoke(cli, ["code", "add", "billing.py"]) r = runner.invoke(cli, ["commit", "-m", "perf: vectorise compute_total body implementation"]) assert r.exit_code == 0, r.output (repo / "billing.py").write_text(textwrap.dedent("""\ def compute_total(items, currency="USD"): return sum(i["price"] for i in items) """)) runner.invoke(cli, ["code", "add", "billing.py"]) r = runner.invoke(cli, ["commit", "-m", "feat: compute_total signature add currency"]) assert r.exit_code == 0, r.output return repo CMD = ["code", "narrative"] ADDR = "billing.py::compute_total" # --------------------------------------------------------------------------- # -j alias # --------------------------------------------------------------------------- class TestJsonAlias: def test_j_alias_exits_zero(self, narrative_repo: pathlib.Path) -> None: r = runner.invoke(cli, CMD + [ADDR, "-j"]) assert r.exit_code == 0, r.output def test_j_alias_emits_valid_json(self, narrative_repo: pathlib.Path) -> None: r = runner.invoke(cli, CMD + [ADDR, "-j"]) data = json.loads(r.output) assert isinstance(data, dict) def test_j_alias_same_as_json_flag(self, narrative_repo: pathlib.Path) -> None: r1 = runner.invoke(cli, CMD + [ADDR, "--json"]) r2 = runner.invoke(cli, CMD + [ADDR, "-j"]) d1 = json.loads(r1.output) d2 = json.loads(r2.output) # Ignore duration_ms which may differ; compare structural keys. for key in ("address", "name", "kind", "status", "impl_changes", "sig_changes"): assert d1[key] == d2[key], f"mismatch on {key!r}: {d1[key]!r} vs {d2[key]!r}" # --------------------------------------------------------------------------- # exit_code in JSON envelope # --------------------------------------------------------------------------- class TestJsonExitCode: def test_exit_code_present_in_json(self, narrative_repo: pathlib.Path) -> None: r = runner.invoke(cli, CMD + [ADDR, "--json"]) data = json.loads(r.output) assert "exit_code" in data def test_exit_code_is_zero_on_success(self, narrative_repo: pathlib.Path) -> None: r = runner.invoke(cli, CMD + [ADDR, "--json"]) data = json.loads(r.output) assert data["exit_code"] == 0 def test_exit_code_is_int(self, narrative_repo: pathlib.Path) -> None: r = runner.invoke(cli, CMD + [ADDR, "--json"]) data = json.loads(r.output) assert isinstance(data["exit_code"], int) # --------------------------------------------------------------------------- # duration_ms in JSON envelope # --------------------------------------------------------------------------- class TestJsonDurationMs: def test_duration_ms_present_in_json(self, narrative_repo: pathlib.Path) -> None: r = runner.invoke(cli, CMD + [ADDR, "--json"]) data = json.loads(r.output) assert "duration_ms" in data def test_duration_ms_is_positive(self, narrative_repo: pathlib.Path) -> None: r = runner.invoke(cli, CMD + [ADDR, "--json"]) data = json.loads(r.output) assert data["duration_ms"] > 0 def test_duration_ms_is_float_or_int(self, narrative_repo: pathlib.Path) -> None: r = runner.invoke(cli, CMD + [ADDR, "--json"]) data = json.loads(r.output) assert isinstance(data["duration_ms"], (int, float)) # --------------------------------------------------------------------------- # Extra JSON context fields # --------------------------------------------------------------------------- class TestJsonContextFields: def _json(self, narrative_repo: pathlib.Path) -> Mapping[str, object]: r = runner.invoke(cli, CMD + [ADDR, "--json"]) assert r.exit_code == 0, r.output return json.loads(r.output) def test_kind_is_function(self, narrative_repo: pathlib.Path) -> None: data = self._json(narrative_repo) assert data["kind"] == "function" def test_sig_changes_gte_one(self, narrative_repo: pathlib.Path) -> None: data = self._json(narrative_repo) # We made at least one signature change commit. assert data["sig_changes"] >= 1 def test_renames_is_int(self, narrative_repo: pathlib.Path) -> None: data = self._json(narrative_repo) assert isinstance(data["renames"], int) assert data["renames"] >= 0 def test_truncated_false_for_small_repo(self, narrative_repo: pathlib.Path) -> None: data = self._json(narrative_repo) assert data["truncated"] is False def test_last_impl_commit_nonempty_when_impl_exists( self, narrative_repo: pathlib.Path ) -> None: data = self._json(narrative_repo) assert data["impl_changes"] >= 1 assert data["last_impl_commit"] != "" def test_last_impl_date_is_date_format(self, narrative_repo: pathlib.Path) -> None: import re data = self._json(narrative_repo) if data["impl_changes"] >= 1: assert re.match(r"\d{4}-\d{2}-\d{2}", data["last_impl_date"]), ( f"Expected YYYY-MM-DD but got {data['last_impl_date']!r}" ) # --------------------------------------------------------------------------- # JSON is single-line # --------------------------------------------------------------------------- class TestJsonSingleLine: def test_json_output_is_single_line(self, narrative_repo: pathlib.Path) -> None: r = runner.invoke(cli, CMD + [ADDR, "--json"]) lines = [l for l in r.output.splitlines() if l.strip()] assert len(lines) == 1, f"Expected one JSON line, got {len(lines)}: {r.output[:200]}" def test_json_output_parseable_without_strip( self, narrative_repo: pathlib.Path ) -> None: r = runner.invoke(cli, CMD + [ADDR, "--json"]) # Should parse even with trailing newline. data = json.loads(r.output) assert data["address"] == ADDR # --------------------------------------------------------------------------- # TypedDict exports # --------------------------------------------------------------------------- class TestTypedDictExport: def test_narrative_json_typeddict_importable(self) -> None: from muse.cli.commands.narrative import _NarrativeJson import typing hints = typing.get_type_hints(_NarrativeJson) assert "address" in hints assert "exit_code" in hints assert "duration_ms" in hints def test_event_record_typeddict_importable(self) -> None: from muse.cli.commands.narrative import _EventRecord import typing hints = typing.get_type_hints(_EventRecord) for key in ("date", "commit_id", "commit_msg", "event_type", "sem_ver_bump", "detail"): assert key in hints, f"_EventRecord missing key: {key!r}" def test_narrative_json_typeddict_matches_output( self, narrative_repo: pathlib.Path ) -> None: """Every key in the TypedDict must appear in actual JSON output.""" from muse.cli.commands.narrative import _NarrativeJson import typing r = runner.invoke(cli, CMD + [ADDR, "--json"]) data = json.loads(r.output) hints = typing.get_type_hints(_NarrativeJson) for key in hints: assert key in data, f"JSON output missing TypedDict key: {key!r}" # --------------------------------------------------------------------------- # Unit: _classify_op # --------------------------------------------------------------------------- class TestClassifyOp: def _op(self, **kwargs: str) -> Mapping[str, object]: base = {"op": "replace", "new_summary": "", "old_summary": "", "address": "x.py::f"} base.update(kwargs) return base def test_insert_is_create(self) -> None: from muse.cli.commands.narrative import _classify_op assert _classify_op({"op": "insert"}) == "create" def test_delete_is_delete(self) -> None: from muse.cli.commands.narrative import _classify_op assert _classify_op({"op": "delete"}) == "delete" def test_rename_keyword_in_new_summary(self) -> None: from muse.cli.commands.narrative import _classify_op op = self._op(new_summary="renamed foo to bar") assert _classify_op(op) == "rename" def test_moved_keyword_in_new_summary(self) -> None: from muse.cli.commands.narrative import _classify_op op = self._op(new_summary="moved module to package") assert _classify_op(op) == "rename" def test_signature_keyword_in_new_summary(self) -> None: from muse.cli.commands.narrative import _classify_op op = self._op(new_summary="signature change detected") assert _classify_op(op) == "sig" def test_implementation_keyword_in_new_summary(self) -> None: from muse.cli.commands.narrative import _classify_op op = self._op(new_summary="implementation rewritten") assert _classify_op(op) == "impl" def test_body_keyword_in_old_summary_is_impl(self) -> None: from muse.cli.commands.narrative import _classify_op op = self._op(new_summary="", old_summary="body changed completely") assert _classify_op(op) == "impl" def test_unknown_replace_defaults_to_impl(self) -> None: from muse.cli.commands.narrative import _classify_op op = self._op(new_summary="some unrecognized text") assert _classify_op(op) == "impl" def test_other_op_kind_returns_other(self) -> None: from muse.cli.commands.narrative import _classify_op assert _classify_op({"op": "unknown_op"}) == "other" # --------------------------------------------------------------------------- # Unit: _sanitise_msg # --------------------------------------------------------------------------- class TestSanitiseMsg: def test_strips_control_chars(self) -> None: from muse.cli.commands.narrative import _sanitise_msg # ESC + some control chars result = _sanitise_msg("\x1b[31mred\x1b[0m") assert "\x1b" not in result assert "red" in result def test_truncates_at_72(self) -> None: from muse.cli.commands.narrative import _sanitise_msg long_msg = "x" * 100 result = _sanitise_msg(long_msg) assert len(result) <= 72 def test_short_message_unchanged(self) -> None: from muse.cli.commands.narrative import _sanitise_msg msg = "feat: add compute_total" assert _sanitise_msg(msg) == msg def test_trailing_ellipsis_on_truncation(self) -> None: from muse.cli.commands.narrative import _sanitise_msg result = _sanitise_msg("a" * 100) assert result.endswith("…") def test_null_byte_stripped(self) -> None: from muse.cli.commands.narrative import _sanitise_msg result = _sanitise_msg("hello\x00world") assert "\x00" not in result # --------------------------------------------------------------------------- # Unit: _format_date / _format_date_long # --------------------------------------------------------------------------- class TestFormatDate: def _dt(self, year: int = 2026, month: int = 1, day: int = 12) -> datetime.datetime: return datetime.datetime(year, month, day, tzinfo=datetime.timezone.utc) def test_format_date_basic(self) -> None: from muse.cli.commands.narrative import _format_date result = _format_date(self._dt(2026, 1, 12)) assert "Jan" in result assert "12" in result assert "2026" in result def test_format_date_no_double_space(self) -> None: from muse.cli.commands.narrative import _format_date # Day 1 could produce double space; must be collapsed. result = _format_date(self._dt(2026, 3, 1)) assert " " not in result def test_format_date_long_st_suffix(self) -> None: from muse.cli.commands.narrative import _format_date_long result = _format_date_long(self._dt(2026, 1, 1)) assert "1st" in result def test_format_date_long_nd_suffix(self) -> None: from muse.cli.commands.narrative import _format_date_long result = _format_date_long(self._dt(2026, 1, 2)) assert "2nd" in result def test_format_date_long_rd_suffix(self) -> None: from muse.cli.commands.narrative import _format_date_long result = _format_date_long(self._dt(2026, 1, 3)) assert "3rd" in result def test_format_date_long_th_suffix(self) -> None: from muse.cli.commands.narrative import _format_date_long result = _format_date_long(self._dt(2026, 1, 4)) assert "4th" in result def test_format_date_long_11th_exception(self) -> None: from muse.cli.commands.narrative import _format_date_long # 11th should be 'th' not 'st' result = _format_date_long(self._dt(2026, 1, 11)) assert "11th" in result def test_format_date_long_12th_exception(self) -> None: from muse.cli.commands.narrative import _format_date_long result = _format_date_long(self._dt(2026, 1, 12)) assert "12th" in result def test_format_date_long_13th_exception(self) -> None: from muse.cli.commands.narrative import _format_date_long result = _format_date_long(self._dt(2026, 1, 13)) assert "13th" in result # --------------------------------------------------------------------------- # Unit: _days_ago # --------------------------------------------------------------------------- class TestDaysAgo: def _dt(self, days_ago: int) -> datetime.datetime: now = datetime.datetime.now(tz=datetime.timezone.utc) return now - datetime.timedelta(days=days_ago) def test_today(self) -> None: from muse.cli.commands.narrative import _days_ago assert _days_ago(self._dt(0)) == "today" def test_yesterday(self) -> None: from muse.cli.commands.narrative import _days_ago assert _days_ago(self._dt(1)) == "1 day ago" def test_few_days(self) -> None: from muse.cli.commands.narrative import _days_ago result = _days_ago(self._dt(5)) assert "days ago" in result def test_weeks(self) -> None: from muse.cli.commands.narrative import _days_ago result = _days_ago(self._dt(14)) assert "wk ago" in result def test_months(self) -> None: from muse.cli.commands.narrative import _days_ago result = _days_ago(self._dt(60)) assert "mo ago" in result def test_years(self) -> None: from muse.cli.commands.narrative import _days_ago result = _days_ago(self._dt(400)) assert "yr" in result def test_none_returns_unknown(self) -> None: from muse.cli.commands.narrative import _days_ago assert _days_ago(None) == "unknown" # --------------------------------------------------------------------------- # Unit: _relative_to # --------------------------------------------------------------------------- class TestRelativeTo: def _dt(self, year: int, month: int, day: int) -> datetime.datetime: return datetime.datetime(year, month, day) def test_same_day(self) -> None: from muse.cli.commands.narrative import _relative_to d = self._dt(2026, 1, 12) assert _relative_to(d, d) == "the same day" def test_one_day_later(self) -> None: from muse.cli.commands.narrative import _relative_to d1 = self._dt(2026, 1, 12) d2 = self._dt(2026, 1, 13) assert _relative_to(d1, d2) == "1 day later" def test_days_later(self) -> None: from muse.cli.commands.narrative import _relative_to d1 = self._dt(2026, 1, 12) d2 = self._dt(2026, 1, 17) result = _relative_to(d1, d2) assert "days later" in result def test_weeks_later(self) -> None: from muse.cli.commands.narrative import _relative_to d1 = self._dt(2026, 1, 1) d2 = self._dt(2026, 1, 15) # 14 days = 2 weeks result = _relative_to(d1, d2) assert "week" in result and "later" in result def test_months_later(self) -> None: from muse.cli.commands.narrative import _relative_to d1 = self._dt(2026, 1, 1) d2 = self._dt(2026, 4, 1) # ~90 days result = _relative_to(d1, d2) assert "month" in result and "later" in result def test_years_later(self) -> None: from muse.cli.commands.narrative import _relative_to d1 = self._dt(2024, 1, 1) d2 = self._dt(2026, 1, 1) result = _relative_to(d1, d2) assert "year" in result and "later" in result # --------------------------------------------------------------------------- # Unit: _extract_rename # --------------------------------------------------------------------------- class TestExtractRename: def test_renamed_x_to_y_pattern(self) -> None: from muse.cli.commands.narrative import _extract_rename old, new = _extract_rename("renamed foo to bar", "") assert old == "foo" assert new == "bar" def test_moved_x_to_y_pattern(self) -> None: from muse.cli.commands.narrative import _extract_rename old, new = _extract_rename("moved old_name to new_name", "") assert old == "old_name" assert new == "new_name" def test_fallback_from_colons(self) -> None: from muse.cli.commands.narrative import _extract_rename old, new = _extract_rename("billing.py::new_func", "billing.py::old_func") assert old == "old_func" assert new == "new_func" def test_empty_summaries_return_empty(self) -> None: from muse.cli.commands.narrative import _extract_rename old, new = _extract_rename("", "") assert old == "" assert new == "" def test_case_insensitive_match(self) -> None: from muse.cli.commands.narrative import _extract_rename old, new = _extract_rename("Renamed Alpha to Beta", "") assert old == "Alpha" assert new == "Beta" # --------------------------------------------------------------------------- # Unit: _event_detail # --------------------------------------------------------------------------- class TestEventDetail: def _raw_event(self, event_type: str, new_sum: str = "", old_sum: str = "") -> "_RawEvent": from muse.cli.commands.narrative import _RawEvent import datetime return _RawEvent( ts=datetime.datetime(2026, 1, 12), commit_id="abc1234", commit_msg="feat: something", sem_ver_bump="minor", event_type=event_type, op_new_summary=new_sum, op_old_summary=old_sum, ) def test_rename_event_extracts_arrow(self) -> None: from muse.cli.commands.narrative import _event_detail ev = self._raw_event("rename", "renamed foo to bar", "") result = _event_detail(ev) assert "foo" in result and "bar" in result def test_create_event_returns_summary(self) -> None: from muse.cli.commands.narrative import _event_detail ev = self._raw_event("create", "Created as a function taking 2 params.") result = _event_detail(ev) assert "Created" in result def test_impl_event_returns_empty(self) -> None: from muse.cli.commands.narrative import _event_detail ev = self._raw_event("impl", "") # For impl events with no special content, detail is empty. result = _event_detail(ev) assert isinstance(result, str) # --------------------------------------------------------------------------- # --show-source with --json does not crash # --------------------------------------------------------------------------- class TestShowSourceWithJson: def test_show_source_json_combination_does_not_crash( self, narrative_repo: pathlib.Path ) -> None: """--show-source is ignored in JSON mode; command must not crash.""" r = runner.invoke(cli, CMD + [ADDR, "--json", "--show-source"]) # Should exit zero even if --show-source is silently ignored in JSON mode. assert r.exit_code == 0, r.output data = json.loads(r.output) assert "address" in data class TestRegisterFlags: def _parse(self, *args: str) -> "argparse.Namespace": import argparse from muse.cli.commands.narrative import register p = argparse.ArgumentParser() top_sub = p.add_subparsers() code_p = top_sub.add_parser("code") code_sub = code_p.add_subparsers() register(code_sub) return p.parse_args(["code", "narrative", "dummy.py::fn", *args]) def test_json_short_flag(self) -> None: args = self._parse("-j") assert args.json_out is True def test_json_long_flag(self) -> None: args = self._parse("--json") assert args.json_out is True def test_default_no_json(self) -> None: args = self._parse() assert args.json_out is False