gabriel / muse public
test_narrative_supercharge.py python
625 lines 23.3 KB
Raw
sha256:74b5023693ac2ab80e3b89fddc66e0d60d7d931a1266d3f9294f645c3102fe76 tests/test_lineage_supercharge.py, tests/test_narrative_sup… Human 15 days ago
1 """Supercharge tests for ``muse code narrative``.
2
3 Coverage gaps addressed
4 -----------------------
5 - ``-j`` alias for ``--json``
6 - ``exit_code`` field in JSON envelope
7 - ``duration_ms`` field in JSON envelope
8 - ``sig_changes`` / ``renames`` counts verified in JSON
9 - ``truncated`` is False for small repos
10 - ``kind`` is correct in JSON
11 - ``last_impl_date`` / ``last_impl_commit`` present and non-empty when impl exists
12 - JSON is a single line (machine-parseable)
13 - TypedDict exports: ``_NarrativeJson`` and ``_EventRecord`` importable, match output
14 - Unit: ``_classify_op`` all branches
15 - Unit: ``_sanitise_msg`` truncation and control-char stripping
16 - Unit: ``_format_date`` / ``_format_date_long``
17 - Unit: ``_days_ago`` buckets
18 - Unit: ``_relative_to`` buckets
19 - Unit: ``_extract_rename`` patterns
20 - Unit: ``_event_detail``
21 - ``--show-source`` does not crash with ``--json`` mode
22 """
23
24 from __future__ import annotations
25 from collections.abc import Mapping
26
27 import datetime
28 import json
29 import pathlib
30 import textwrap
31
32 import pytest
33 from tests.cli_test_helper import CliRunner
34
35 cli = None # CliRunner ignores this argument; see cli_test_helper.py
36 runner = CliRunner()
37
38
39 # ---------------------------------------------------------------------------
40 # Base repo fixture
41 # ---------------------------------------------------------------------------
42
43
44 @pytest.fixture()
45 def repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
46 monkeypatch.chdir(tmp_path)
47 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
48 r = runner.invoke(cli, ["init", "--domain", "code"])
49 assert r.exit_code == 0, r.output
50 return tmp_path
51
52
53 # ---------------------------------------------------------------------------
54 # Shared fixture — minimal repo with a symbol that has a rich history
55 # ---------------------------------------------------------------------------
56
57
58 @pytest.fixture()
59 def narrative_repo(repo: pathlib.Path) -> pathlib.Path:
60 """Repo with billing.py::compute_total across four commits.
61
62 Commit 1 — seed (readme only)
63 Commit 2 — create billing.py with compute_total
64 Commit 3 — body rewrite (impl)
65 Commit 4 — signature change
66 """
67 (repo / "readme.txt").write_text("seed\n")
68 r = runner.invoke(cli, ["commit", "-m", "chore: seed"])
69 assert r.exit_code == 0, r.output
70
71 (repo / "billing.py").write_text(textwrap.dedent("""\
72 def compute_total(items):
73 total = 0
74 for item in items:
75 total += item["price"]
76 return total
77 """))
78 r = runner.invoke(cli, ["commit", "-m", "feat: add compute_total"])
79 assert r.exit_code == 0, r.output
80
81 (repo / "billing.py").write_text(textwrap.dedent("""\
82 def compute_total(items):
83 return sum(i["price"] for i in items)
84 """))
85 r = runner.invoke(cli, ["commit", "-m", "perf: vectorise compute_total body implementation"])
86 assert r.exit_code == 0, r.output
87
88 (repo / "billing.py").write_text(textwrap.dedent("""\
89 def compute_total(items, currency="USD"):
90 return sum(i["price"] for i in items)
91 """))
92 r = runner.invoke(cli, ["commit", "-m", "feat: compute_total signature add currency"])
93 assert r.exit_code == 0, r.output
94
95 return repo
96
97
98 CMD = ["code", "narrative"]
99 ADDR = "billing.py::compute_total"
100
101
102 # ---------------------------------------------------------------------------
103 # -j alias
104 # ---------------------------------------------------------------------------
105
106
107 class TestJsonAlias:
108 def test_j_alias_exits_zero(self, narrative_repo: pathlib.Path) -> None:
109 r = runner.invoke(cli, CMD + [ADDR, "-j"])
110 assert r.exit_code == 0, r.output
111
112 def test_j_alias_emits_valid_json(self, narrative_repo: pathlib.Path) -> None:
113 r = runner.invoke(cli, CMD + [ADDR, "-j"])
114 data = json.loads(r.output)
115 assert isinstance(data, dict)
116
117 def test_j_alias_same_as_json_flag(self, narrative_repo: pathlib.Path) -> None:
118 r1 = runner.invoke(cli, CMD + [ADDR, "--json"])
119 r2 = runner.invoke(cli, CMD + [ADDR, "-j"])
120 d1 = json.loads(r1.output)
121 d2 = json.loads(r2.output)
122 # Ignore duration_ms which may differ; compare structural keys.
123 for key in ("address", "name", "kind", "status", "impl_changes", "sig_changes"):
124 assert d1[key] == d2[key], f"mismatch on {key!r}: {d1[key]!r} vs {d2[key]!r}"
125
126
127 # ---------------------------------------------------------------------------
128 # exit_code in JSON envelope
129 # ---------------------------------------------------------------------------
130
131
132 class TestJsonExitCode:
133 def test_exit_code_present_in_json(self, narrative_repo: pathlib.Path) -> None:
134 r = runner.invoke(cli, CMD + [ADDR, "--json"])
135 data = json.loads(r.output)
136 assert "exit_code" in data
137
138 def test_exit_code_is_zero_on_success(self, narrative_repo: pathlib.Path) -> None:
139 r = runner.invoke(cli, CMD + [ADDR, "--json"])
140 data = json.loads(r.output)
141 assert data["exit_code"] == 0
142
143 def test_exit_code_is_int(self, narrative_repo: pathlib.Path) -> None:
144 r = runner.invoke(cli, CMD + [ADDR, "--json"])
145 data = json.loads(r.output)
146 assert isinstance(data["exit_code"], int)
147
148
149 # ---------------------------------------------------------------------------
150 # duration_ms in JSON envelope
151 # ---------------------------------------------------------------------------
152
153
154 class TestJsonDurationMs:
155 def test_duration_ms_present_in_json(self, narrative_repo: pathlib.Path) -> None:
156 r = runner.invoke(cli, CMD + [ADDR, "--json"])
157 data = json.loads(r.output)
158 assert "duration_ms" in data
159
160 def test_duration_ms_is_positive(self, narrative_repo: pathlib.Path) -> None:
161 r = runner.invoke(cli, CMD + [ADDR, "--json"])
162 data = json.loads(r.output)
163 assert data["duration_ms"] > 0
164
165 def test_duration_ms_is_float_or_int(self, narrative_repo: pathlib.Path) -> None:
166 r = runner.invoke(cli, CMD + [ADDR, "--json"])
167 data = json.loads(r.output)
168 assert isinstance(data["duration_ms"], (int, float))
169
170
171 # ---------------------------------------------------------------------------
172 # Extra JSON context fields
173 # ---------------------------------------------------------------------------
174
175
176 class TestJsonContextFields:
177 def _json(self, narrative_repo: pathlib.Path) -> Mapping[str, object]:
178 r = runner.invoke(cli, CMD + [ADDR, "--json"])
179 assert r.exit_code == 0, r.output
180 return json.loads(r.output)
181
182 def test_kind_is_function(self, narrative_repo: pathlib.Path) -> None:
183 data = self._json(narrative_repo)
184 assert data["kind"] == "function"
185
186 def test_sig_changes_gte_one(self, narrative_repo: pathlib.Path) -> None:
187 data = self._json(narrative_repo)
188 # We made at least one signature change commit.
189 assert data["sig_changes"] >= 1
190
191 def test_renames_is_int(self, narrative_repo: pathlib.Path) -> None:
192 data = self._json(narrative_repo)
193 assert isinstance(data["renames"], int)
194 assert data["renames"] >= 0
195
196 def test_truncated_false_for_small_repo(self, narrative_repo: pathlib.Path) -> None:
197 data = self._json(narrative_repo)
198 assert data["truncated"] is False
199
200 def test_last_impl_commit_nonempty_when_impl_exists(
201 self, narrative_repo: pathlib.Path
202 ) -> None:
203 data = self._json(narrative_repo)
204 assert data["impl_changes"] >= 1
205 assert data["last_impl_commit"] != ""
206
207 def test_last_impl_date_is_date_format(self, narrative_repo: pathlib.Path) -> None:
208 import re
209 data = self._json(narrative_repo)
210 if data["impl_changes"] >= 1:
211 assert re.match(r"\d{4}-\d{2}-\d{2}", data["last_impl_date"]), (
212 f"Expected YYYY-MM-DD but got {data['last_impl_date']!r}"
213 )
214
215
216 # ---------------------------------------------------------------------------
217 # JSON is single-line
218 # ---------------------------------------------------------------------------
219
220
221 class TestJsonSingleLine:
222 def test_json_output_is_single_line(self, narrative_repo: pathlib.Path) -> None:
223 r = runner.invoke(cli, CMD + [ADDR, "--json"])
224 lines = [l for l in r.output.splitlines() if l.strip()]
225 assert len(lines) == 1, f"Expected one JSON line, got {len(lines)}: {r.output[:200]}"
226
227 def test_json_output_parseable_without_strip(
228 self, narrative_repo: pathlib.Path
229 ) -> None:
230 r = runner.invoke(cli, CMD + [ADDR, "--json"])
231 # Should parse even with trailing newline.
232 data = json.loads(r.output)
233 assert data["address"] == ADDR
234
235
236 # ---------------------------------------------------------------------------
237 # TypedDict exports
238 # ---------------------------------------------------------------------------
239
240
241 class TestTypedDictExport:
242 def test_narrative_json_typeddict_importable(self) -> None:
243 from muse.cli.commands.narrative import _NarrativeJson
244 import typing
245 hints = typing.get_type_hints(_NarrativeJson)
246 assert "address" in hints
247 assert "exit_code" in hints
248 assert "duration_ms" in hints
249
250 def test_event_record_typeddict_importable(self) -> None:
251 from muse.cli.commands.narrative import _EventRecord
252 import typing
253 hints = typing.get_type_hints(_EventRecord)
254 for key in ("date", "commit_id", "commit_msg", "event_type", "sem_ver_bump", "detail"):
255 assert key in hints, f"_EventRecord missing key: {key!r}"
256
257 def test_narrative_json_typeddict_matches_output(
258 self, narrative_repo: pathlib.Path
259 ) -> None:
260 """Every key in the TypedDict must appear in actual JSON output."""
261 from muse.cli.commands.narrative import _NarrativeJson
262 import typing
263 r = runner.invoke(cli, CMD + [ADDR, "--json"])
264 data = json.loads(r.output)
265 hints = typing.get_type_hints(_NarrativeJson)
266 for key in hints:
267 assert key in data, f"JSON output missing TypedDict key: {key!r}"
268
269
270 # ---------------------------------------------------------------------------
271 # Unit: _classify_op
272 # ---------------------------------------------------------------------------
273
274
275 class TestClassifyOp:
276 def _op(self, **kwargs: str) -> Mapping[str, object]:
277 base = {"op": "replace", "new_summary": "", "old_summary": "", "address": "x.py::f"}
278 base.update(kwargs)
279 return base
280
281 def test_insert_is_create(self) -> None:
282 from muse.cli.commands.narrative import _classify_op
283 assert _classify_op({"op": "insert"}) == "create"
284
285 def test_delete_is_delete(self) -> None:
286 from muse.cli.commands.narrative import _classify_op
287 assert _classify_op({"op": "delete"}) == "delete"
288
289 def test_rename_keyword_in_new_summary(self) -> None:
290 from muse.cli.commands.narrative import _classify_op
291 op = self._op(new_summary="renamed foo to bar")
292 assert _classify_op(op) == "rename"
293
294 def test_moved_keyword_in_new_summary(self) -> None:
295 from muse.cli.commands.narrative import _classify_op
296 op = self._op(new_summary="moved module to package")
297 assert _classify_op(op) == "rename"
298
299 def test_signature_keyword_in_new_summary(self) -> None:
300 from muse.cli.commands.narrative import _classify_op
301 op = self._op(new_summary="signature change detected")
302 assert _classify_op(op) == "sig"
303
304 def test_implementation_keyword_in_new_summary(self) -> None:
305 from muse.cli.commands.narrative import _classify_op
306 op = self._op(new_summary="implementation rewritten")
307 assert _classify_op(op) == "impl"
308
309 def test_body_keyword_in_old_summary_is_impl(self) -> None:
310 from muse.cli.commands.narrative import _classify_op
311 op = self._op(new_summary="", old_summary="body changed completely")
312 assert _classify_op(op) == "impl"
313
314 def test_unknown_replace_defaults_to_impl(self) -> None:
315 from muse.cli.commands.narrative import _classify_op
316 op = self._op(new_summary="some unrecognized text")
317 assert _classify_op(op) == "impl"
318
319 def test_other_op_kind_returns_other(self) -> None:
320 from muse.cli.commands.narrative import _classify_op
321 assert _classify_op({"op": "unknown_op"}) == "other"
322
323
324 # ---------------------------------------------------------------------------
325 # Unit: _sanitise_msg
326 # ---------------------------------------------------------------------------
327
328
329 class TestSanitiseMsg:
330 def test_strips_control_chars(self) -> None:
331 from muse.cli.commands.narrative import _sanitise_msg
332 # ESC + some control chars
333 result = _sanitise_msg("\x1b[31mred\x1b[0m")
334 assert "\x1b" not in result
335 assert "red" in result
336
337 def test_truncates_at_72(self) -> None:
338 from muse.cli.commands.narrative import _sanitise_msg
339 long_msg = "x" * 100
340 result = _sanitise_msg(long_msg)
341 assert len(result) <= 72
342
343 def test_short_message_unchanged(self) -> None:
344 from muse.cli.commands.narrative import _sanitise_msg
345 msg = "feat: add compute_total"
346 assert _sanitise_msg(msg) == msg
347
348 def test_trailing_ellipsis_on_truncation(self) -> None:
349 from muse.cli.commands.narrative import _sanitise_msg
350 result = _sanitise_msg("a" * 100)
351 assert result.endswith("…")
352
353 def test_null_byte_stripped(self) -> None:
354 from muse.cli.commands.narrative import _sanitise_msg
355 result = _sanitise_msg("hello\x00world")
356 assert "\x00" not in result
357
358
359 # ---------------------------------------------------------------------------
360 # Unit: _format_date / _format_date_long
361 # ---------------------------------------------------------------------------
362
363
364 class TestFormatDate:
365 def _dt(self, year: int = 2026, month: int = 1, day: int = 12) -> datetime.datetime:
366 return datetime.datetime(year, month, day, tzinfo=datetime.timezone.utc)
367
368 def test_format_date_basic(self) -> None:
369 from muse.cli.commands.narrative import _format_date
370 result = _format_date(self._dt(2026, 1, 12))
371 assert "Jan" in result
372 assert "12" in result
373 assert "2026" in result
374
375 def test_format_date_no_double_space(self) -> None:
376 from muse.cli.commands.narrative import _format_date
377 # Day 1 could produce double space; must be collapsed.
378 result = _format_date(self._dt(2026, 3, 1))
379 assert " " not in result
380
381 def test_format_date_long_st_suffix(self) -> None:
382 from muse.cli.commands.narrative import _format_date_long
383 result = _format_date_long(self._dt(2026, 1, 1))
384 assert "1st" in result
385
386 def test_format_date_long_nd_suffix(self) -> None:
387 from muse.cli.commands.narrative import _format_date_long
388 result = _format_date_long(self._dt(2026, 1, 2))
389 assert "2nd" in result
390
391 def test_format_date_long_rd_suffix(self) -> None:
392 from muse.cli.commands.narrative import _format_date_long
393 result = _format_date_long(self._dt(2026, 1, 3))
394 assert "3rd" in result
395
396 def test_format_date_long_th_suffix(self) -> None:
397 from muse.cli.commands.narrative import _format_date_long
398 result = _format_date_long(self._dt(2026, 1, 4))
399 assert "4th" in result
400
401 def test_format_date_long_11th_exception(self) -> None:
402 from muse.cli.commands.narrative import _format_date_long
403 # 11th should be 'th' not 'st'
404 result = _format_date_long(self._dt(2026, 1, 11))
405 assert "11th" in result
406
407 def test_format_date_long_12th_exception(self) -> None:
408 from muse.cli.commands.narrative import _format_date_long
409 result = _format_date_long(self._dt(2026, 1, 12))
410 assert "12th" in result
411
412 def test_format_date_long_13th_exception(self) -> None:
413 from muse.cli.commands.narrative import _format_date_long
414 result = _format_date_long(self._dt(2026, 1, 13))
415 assert "13th" in result
416
417
418 # ---------------------------------------------------------------------------
419 # Unit: _days_ago
420 # ---------------------------------------------------------------------------
421
422
423 class TestDaysAgo:
424 def _dt(self, days_ago: int) -> datetime.datetime:
425 now = datetime.datetime.now(tz=datetime.timezone.utc)
426 return now - datetime.timedelta(days=days_ago)
427
428 def test_today(self) -> None:
429 from muse.cli.commands.narrative import _days_ago
430 assert _days_ago(self._dt(0)) == "today"
431
432 def test_yesterday(self) -> None:
433 from muse.cli.commands.narrative import _days_ago
434 assert _days_ago(self._dt(1)) == "1 day ago"
435
436 def test_few_days(self) -> None:
437 from muse.cli.commands.narrative import _days_ago
438 result = _days_ago(self._dt(5))
439 assert "days ago" in result
440
441 def test_weeks(self) -> None:
442 from muse.cli.commands.narrative import _days_ago
443 result = _days_ago(self._dt(14))
444 assert "wk ago" in result
445
446 def test_months(self) -> None:
447 from muse.cli.commands.narrative import _days_ago
448 result = _days_ago(self._dt(60))
449 assert "mo ago" in result
450
451 def test_years(self) -> None:
452 from muse.cli.commands.narrative import _days_ago
453 result = _days_ago(self._dt(400))
454 assert "yr" in result
455
456 def test_none_returns_unknown(self) -> None:
457 from muse.cli.commands.narrative import _days_ago
458 assert _days_ago(None) == "unknown"
459
460
461 # ---------------------------------------------------------------------------
462 # Unit: _relative_to
463 # ---------------------------------------------------------------------------
464
465
466 class TestRelativeTo:
467 def _dt(self, year: int, month: int, day: int) -> datetime.datetime:
468 return datetime.datetime(year, month, day)
469
470 def test_same_day(self) -> None:
471 from muse.cli.commands.narrative import _relative_to
472 d = self._dt(2026, 1, 12)
473 assert _relative_to(d, d) == "the same day"
474
475 def test_one_day_later(self) -> None:
476 from muse.cli.commands.narrative import _relative_to
477 d1 = self._dt(2026, 1, 12)
478 d2 = self._dt(2026, 1, 13)
479 assert _relative_to(d1, d2) == "1 day later"
480
481 def test_days_later(self) -> None:
482 from muse.cli.commands.narrative import _relative_to
483 d1 = self._dt(2026, 1, 12)
484 d2 = self._dt(2026, 1, 17)
485 result = _relative_to(d1, d2)
486 assert "days later" in result
487
488 def test_weeks_later(self) -> None:
489 from muse.cli.commands.narrative import _relative_to
490 d1 = self._dt(2026, 1, 1)
491 d2 = self._dt(2026, 1, 15) # 14 days = 2 weeks
492 result = _relative_to(d1, d2)
493 assert "week" in result and "later" in result
494
495 def test_months_later(self) -> None:
496 from muse.cli.commands.narrative import _relative_to
497 d1 = self._dt(2026, 1, 1)
498 d2 = self._dt(2026, 4, 1) # ~90 days
499 result = _relative_to(d1, d2)
500 assert "month" in result and "later" in result
501
502 def test_years_later(self) -> None:
503 from muse.cli.commands.narrative import _relative_to
504 d1 = self._dt(2024, 1, 1)
505 d2 = self._dt(2026, 1, 1)
506 result = _relative_to(d1, d2)
507 assert "year" in result and "later" in result
508
509
510 # ---------------------------------------------------------------------------
511 # Unit: _extract_rename
512 # ---------------------------------------------------------------------------
513
514
515 class TestExtractRename:
516 def test_renamed_x_to_y_pattern(self) -> None:
517 from muse.cli.commands.narrative import _extract_rename
518 old, new = _extract_rename("renamed foo to bar", "")
519 assert old == "foo"
520 assert new == "bar"
521
522 def test_moved_x_to_y_pattern(self) -> None:
523 from muse.cli.commands.narrative import _extract_rename
524 old, new = _extract_rename("moved old_name to new_name", "")
525 assert old == "old_name"
526 assert new == "new_name"
527
528 def test_fallback_from_colons(self) -> None:
529 from muse.cli.commands.narrative import _extract_rename
530 old, new = _extract_rename("billing.py::new_func", "billing.py::old_func")
531 assert old == "old_func"
532 assert new == "new_func"
533
534 def test_empty_summaries_return_empty(self) -> None:
535 from muse.cli.commands.narrative import _extract_rename
536 old, new = _extract_rename("", "")
537 assert old == ""
538 assert new == ""
539
540 def test_case_insensitive_match(self) -> None:
541 from muse.cli.commands.narrative import _extract_rename
542 old, new = _extract_rename("Renamed Alpha to Beta", "")
543 assert old == "Alpha"
544 assert new == "Beta"
545
546
547 # ---------------------------------------------------------------------------
548 # Unit: _event_detail
549 # ---------------------------------------------------------------------------
550
551
552 class TestEventDetail:
553 def _raw_event(self, event_type: str, new_sum: str = "", old_sum: str = "") -> "_RawEvent":
554 from muse.cli.commands.narrative import _RawEvent
555 import datetime
556 return _RawEvent(
557 ts=datetime.datetime(2026, 1, 12),
558 commit_id="abc1234",
559 commit_msg="feat: something",
560 sem_ver_bump="minor",
561 event_type=event_type,
562 op_new_summary=new_sum,
563 op_old_summary=old_sum,
564 )
565
566 def test_rename_event_extracts_arrow(self) -> None:
567 from muse.cli.commands.narrative import _event_detail
568 ev = self._raw_event("rename", "renamed foo to bar", "")
569 result = _event_detail(ev)
570 assert "foo" in result and "bar" in result
571
572 def test_create_event_returns_summary(self) -> None:
573 from muse.cli.commands.narrative import _event_detail
574 ev = self._raw_event("create", "Created as a function taking 2 params.")
575 result = _event_detail(ev)
576 assert "Created" in result
577
578 def test_impl_event_returns_empty(self) -> None:
579 from muse.cli.commands.narrative import _event_detail
580 ev = self._raw_event("impl", "")
581 # For impl events with no special content, detail is empty.
582 result = _event_detail(ev)
583 assert isinstance(result, str)
584
585
586 # ---------------------------------------------------------------------------
587 # --show-source with --json does not crash
588 # ---------------------------------------------------------------------------
589
590
591 class TestShowSourceWithJson:
592 def test_show_source_json_combination_does_not_crash(
593 self, narrative_repo: pathlib.Path
594 ) -> None:
595 """--show-source is ignored in JSON mode; command must not crash."""
596 r = runner.invoke(cli, CMD + [ADDR, "--json", "--show-source"])
597 # Should exit zero even if --show-source is silently ignored in JSON mode.
598 assert r.exit_code == 0, r.output
599 data = json.loads(r.output)
600 assert "address" in data
601
602
603 class TestRegisterFlags:
604 def _parse(self, *args: str) -> "argparse.Namespace":
605 import argparse
606 from muse.cli.commands.narrative import register
607 p = argparse.ArgumentParser()
608 top_sub = p.add_subparsers()
609 code_p = top_sub.add_parser("code")
610 code_sub = code_p.add_subparsers()
611 register(code_sub)
612 return p.parse_args(["code", "narrative", "dummy.py::fn", *args])
613
614 def test_json_short_flag(self) -> None:
615 args = self._parse("-j")
616 assert args.json_out is True
617
618 def test_json_long_flag(self) -> None:
619 args = self._parse("--json")
620 assert args.json_out is True
621
622 def test_default_no_json(self) -> None:
623 args = self._parse()
624 assert args.json_out is False
625
File History 1 commit
sha256:74b5023693ac2ab80e3b89fddc66e0d60d7d931a1266d3f9294f645c3102fe76 tests/test_lineage_supercharge.py, tests/test_narrative_sup… Human 15 days ago