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