gabriel / muse public
test_api_surface.py python
683 lines 25.3 KB
Raw
1 """Comprehensive tests for muse code api-surface.
2
3 Test layers
4 -----------
5 Unit
6 Pure functions: _is_public, _classify_change, _semver_impact,
7 _stability_pct, _ApiEntry.to_dict. Zero I/O.
8
9 Integration
10 CLI invocations against a real tmp-path repo built from the shared
11 fixtures. Covers: list mode, diff mode, --json schema, --count,
12 --language, --file, --breaking, --commit.
13
14 Edge-case
15 Empty API surface, no commits, invalid ref, --breaking without --diff,
16 private symbol filtering, mixed symbol kinds.
17
18 Stress
19 50-file snapshot and 100-commit diff history exercise the shared
20 SymbolCache path and confirm sub-second latency.
21 """
22
23 from __future__ import annotations
24
25 import json
26 import pathlib
27 import textwrap
28 import time
29
30 import pytest
31
32 from tests.cli_test_helper import CliRunner
33 from muse.cli.commands.api_surface import (
34 _ApiEntry,
35 _BREAKING_CHANGES,
36 _classify_change,
37 _is_public,
38 _semver_impact,
39 _stability_pct,
40 )
41 from muse.plugins.code.ast_parser import SymbolRecord, SymbolTree
42
43 type _ChangedMap = dict[str, tuple[SymbolRecord, SymbolRecord, str]]
44
45 cli = None # argparse migration — CliRunner ignores this arg
46 runner = CliRunner()
47
48
49 # ---------------------------------------------------------------------------
50 # Shared fixtures
51 # ---------------------------------------------------------------------------
52
53
54 @pytest.fixture
55 def repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
56 """Fresh code-domain Muse repo."""
57 monkeypatch.chdir(tmp_path)
58 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
59 r = runner.invoke(cli, ["init", "--domain", "code"])
60 assert r.exit_code == 0, r.output
61 return tmp_path
62
63
64 @pytest.fixture
65 def code_repo(repo: pathlib.Path) -> pathlib.Path:
66 """Two-commit repo: billing module created then extended."""
67 (repo / "billing.py").write_text(textwrap.dedent("""\
68 class Invoice:
69 def compute_total(self, items):
70 return sum(items)
71
72 def _validate(self, items):
73 return bool(items)
74
75 def process_order(invoice, items):
76 return invoice.compute_total(items)
77
78 def _internal_helper():
79 pass
80 """))
81 runner.invoke(cli, ["code", "add", "."])
82 r = runner.invoke(cli, ["commit", "-m", "Initial billing module"])
83 assert r.exit_code == 0, r.output
84
85 (repo / "billing.py").write_text(textwrap.dedent("""\
86 class Invoice:
87 def compute_total(self, items, currency="USD"):
88 return sum(items)
89
90 def apply_discount(self, total, pct):
91 return total * (1 - pct)
92
93 def _validate(self, items):
94 return bool(items)
95
96 def process_order(invoice, items):
97 return invoice.compute_total(items)
98
99 def calculate_tax(amount, rate):
100 return amount * rate
101
102 def _internal_helper():
103 pass
104 """))
105 runner.invoke(cli, ["code", "add", "."])
106 r = runner.invoke(cli, ["commit", "-m", "Add discount + tax helpers"])
107 assert r.exit_code == 0, r.output
108 return repo
109
110
111 # ---------------------------------------------------------------------------
112 # Unit: _is_public
113 # ---------------------------------------------------------------------------
114
115
116 class TestIsPublic:
117 def test_public_function(self) -> None:
118 assert _is_public("compute_total", "function") is True
119
120 def test_public_class(self) -> None:
121 assert _is_public("Invoice", "class") is True
122
123 def test_public_method(self) -> None:
124 assert _is_public("Invoice.compute_total", "method") is True
125
126 def test_private_name_rejected(self) -> None:
127 assert _is_public("_internal", "function") is False
128
129 def test_dunder_rejected(self) -> None:
130 assert _is_public("__init__", "method") is False
131
132 def test_import_kind_rejected(self) -> None:
133 assert _is_public("os", "import") is False
134
135 def test_variable_kind_rejected(self) -> None:
136 assert _is_public("MAX_RETRIES", "variable") is False
137
138 def test_async_function_is_public(self) -> None:
139 assert _is_public("fetch_data", "async_function") is True
140
141 def test_async_method_is_public(self) -> None:
142 assert _is_public("SomeClass.run", "async_method") is True
143
144 def test_qualified_private_method_rejected(self) -> None:
145 """Check that the bare name (after last dot) is used for _ prefix detection."""
146 assert _is_public("Invoice._validate", "method") is False
147
148
149 # ---------------------------------------------------------------------------
150 # Unit: _classify_change
151 # ---------------------------------------------------------------------------
152
153
154 def _make_rec(
155 content_id: str = "abc",
156 signature_id: str = "sig",
157 body_hash: str = "body",
158 ) -> SymbolRecord:
159 return SymbolRecord(
160 name="f",
161 kind="function",
162 qualified_name="f",
163 content_id=content_id,
164 signature_id=signature_id,
165 body_hash=body_hash,
166 metadata_id="",
167 canonical_key="test.py#f#function#f#1",
168 lineno=1,
169 end_lineno=2,
170 )
171
172
173 class TestClassifyChange:
174 def test_unchanged(self) -> None:
175 rec = _make_rec("same", "same", "same")
176 assert _classify_change(rec, rec) == "unchanged"
177
178 def test_impl_only(self) -> None:
179 old = _make_rec("old", "sig", "old_body")
180 new = _make_rec("new", "sig", "new_body")
181 assert _classify_change(old, new) == "impl_only"
182
183 def test_signature_change(self) -> None:
184 old = _make_rec("old", "sig_a", "body_a")
185 new = _make_rec("new", "sig_b", "body_a")
186 assert _classify_change(old, new) == "signature_change"
187
188 def test_signature_plus_impl(self) -> None:
189 old = _make_rec("old", "sig_a", "body_a")
190 new = _make_rec("new", "sig_b", "body_b")
191 assert _classify_change(old, new) == "signature+impl"
192
193
194 # ---------------------------------------------------------------------------
195 # Unit: _semver_impact
196 # ---------------------------------------------------------------------------
197
198
199 _EMPTY_SR: SymbolTree = {}
200 _EMPTY_CHANGED: _ChangedMap = {}
201
202
203 def _make_changed(cls: str) -> _ChangedMap:
204 return {"addr": (_make_rec(), _make_rec(), cls)}
205
206
207 class TestSemverImpact:
208 def test_no_changes_is_none(self) -> None:
209 assert _semver_impact(_EMPTY_SR, _EMPTY_SR, _EMPTY_CHANGED) == "NONE"
210
211 def test_removal_is_major(self) -> None:
212 assert _semver_impact(_EMPTY_SR, {"addr": _make_rec()}, _EMPTY_CHANGED) == "MAJOR"
213
214 def test_signature_change_is_major(self) -> None:
215 assert _semver_impact(_EMPTY_SR, _EMPTY_SR, _make_changed("signature_change")) == "MAJOR"
216
217 def test_signature_plus_impl_is_major(self) -> None:
218 assert _semver_impact(_EMPTY_SR, _EMPTY_SR, _make_changed("signature+impl")) == "MAJOR"
219
220 def test_addition_only_is_minor(self) -> None:
221 assert _semver_impact({"addr": _make_rec()}, _EMPTY_SR, _EMPTY_CHANGED) == "MINOR"
222
223 def test_impl_only_change_is_patch(self) -> None:
224 assert _semver_impact(_EMPTY_SR, _EMPTY_SR, _make_changed("impl_only")) == "PATCH"
225
226 def test_removal_beats_addition(self) -> None:
227 """MAJOR wins even when symbols were also added."""
228 assert _semver_impact({"x": _make_rec()}, {"y": _make_rec()}, _EMPTY_CHANGED) == "MAJOR"
229
230
231 # ---------------------------------------------------------------------------
232 # Unit: _stability_pct
233 # ---------------------------------------------------------------------------
234
235
236 class TestStabilityPct:
237 def test_empty_base_is_100(self) -> None:
238 assert _stability_pct(0, _EMPTY_SR, _EMPTY_CHANGED) == 100
239
240 def test_nothing_changed(self) -> None:
241 assert _stability_pct(10, _EMPTY_SR, _EMPTY_CHANGED) == 100
242
243 def test_half_removed(self) -> None:
244 removed = {f"addr_{i}": _make_rec() for i in range(5)}
245 assert _stability_pct(10, removed, _EMPTY_CHANGED) == 50
246
247 def test_all_removed(self) -> None:
248 removed = {f"addr_{i}": _make_rec() for i in range(4)}
249 assert _stability_pct(4, removed, _EMPTY_CHANGED) == 0
250
251 def test_changes_count_toward_instability(self) -> None:
252 changed = _make_changed("impl_only")
253 assert _stability_pct(10, _EMPTY_SR, changed) == 90
254
255
256 # ---------------------------------------------------------------------------
257 # Unit: _ApiEntry.to_dict
258 # ---------------------------------------------------------------------------
259
260
261 class TestApiEntryToDict:
262 def test_full_sha_not_truncated(self) -> None:
263 full_cid = "a" * 64
264 rec = _make_rec(content_id=full_cid, signature_id="s" * 64, body_hash="b" * 64)
265 entry = _ApiEntry("billing.py::f", rec, "Python")
266 d = entry.to_dict()
267 assert d["content_id"] == full_cid
268 assert len(d["content_id"]) == 64
269
270 def test_required_keys_present(self) -> None:
271 rec = _make_rec()
272 entry = _ApiEntry("a.py::f", rec, "Python")
273 d = entry.to_dict()
274 for key in ("address", "kind", "name", "qualified_name", "language",
275 "content_id", "signature_id", "body_hash"):
276 assert key in d
277
278 def test_breaking_changes_set(self) -> None:
279 """Confirm _BREAKING_CHANGES covers the two breaking classifications."""
280 assert "signature_change" in _BREAKING_CHANGES
281 assert "signature+impl" in _BREAKING_CHANGES
282 assert "impl_only" not in _BREAKING_CHANGES
283
284
285 # ---------------------------------------------------------------------------
286 # Integration: list mode
287 # ---------------------------------------------------------------------------
288
289
290 class TestApiSurfaceListMode:
291 def test_exits_zero(self, code_repo: pathlib.Path) -> None:
292 r = runner.invoke(cli, ["code", "api-surface"])
293 assert r.exit_code == 0, r.output
294
295 def test_private_symbols_excluded(self, code_repo: pathlib.Path) -> None:
296 r = runner.invoke(cli, ["code", "api-surface"])
297 assert r.exit_code == 0
298 assert "_validate" not in r.output
299 assert "_internal_helper" not in r.output
300
301 def test_public_symbols_included(self, code_repo: pathlib.Path) -> None:
302 r = runner.invoke(cli, ["code", "api-surface"])
303 assert r.exit_code == 0
304 assert "process_order" in r.output or "Invoice" in r.output
305
306 def test_json_schema(self, code_repo: pathlib.Path) -> None:
307 r = runner.invoke(cli, ["code", "api-surface", "--json"])
308 assert r.exit_code == 0
309 data = json.loads(r.output)
310 assert "commit_id" in data
311 assert "total" in data
312 assert "results" in data
313 assert isinstance(data["results"], list)
314
315 def test_json_commit_id_is_full_sha(self, code_repo: pathlib.Path) -> None:
316 r = runner.invoke(cli, ["code", "api-surface", "--json"])
317 assert r.exit_code == 0
318 data = json.loads(r.output)
319 assert data["commit_id"].startswith("sha256:"), "commit_id must have sha256: prefix"
320
321 def test_json_entry_content_id_is_full_sha(self, code_repo: pathlib.Path) -> None:
322 r = runner.invoke(cli, ["code", "api-surface", "--json"])
323 assert r.exit_code == 0
324 data = json.loads(r.output)
325 for sym in data["results"]:
326 cid = sym["content_id"]
327 assert cid.startswith("sha256:") and len(cid) == 71, (
328 f"content_id must be 'sha256:<64hex>' in {sym['address']}: {cid!r}"
329 )
330
331 def test_count_only_is_integer(self, code_repo: pathlib.Path) -> None:
332 r = runner.invoke(cli, ["code", "api-surface", "--count"])
333 assert r.exit_code == 0
334 assert r.output.strip().isdigit()
335
336 def test_language_filter_python(self, code_repo: pathlib.Path) -> None:
337 r = runner.invoke(cli, ["code", "api-surface", "--language", "Python"])
338 assert r.exit_code == 0
339
340 def test_file_filter_restricts_output(self, code_repo: pathlib.Path) -> None:
341 r = runner.invoke(cli, ["code", "api-surface", "--json", "--file", "billing.py"])
342 assert r.exit_code == 0
343 data = json.loads(r.output)
344 for sym in data["results"]:
345 assert "billing.py" in sym["address"]
346
347 def test_file_filter_nonexistent_returns_empty(self, code_repo: pathlib.Path) -> None:
348 r = runner.invoke(cli, ["code", "api-surface", "--json", "--file", "nonexistent_xyz.py"])
349 assert r.exit_code == 0
350 data = json.loads(r.output)
351 assert data["total"] == 0
352
353 def test_no_commits_handled(self, repo: pathlib.Path) -> None:
354 r = runner.invoke(cli, ["code", "api-surface"])
355 assert r.exit_code in (0, 1)
356
357 def test_invalid_ref_rejected(self, code_repo: pathlib.Path) -> None:
358 r = runner.invoke(cli, ["code", "api-surface", "--commit", "deadbeef0000nonexistent"])
359 assert r.exit_code == 1
360
361 def test_requires_repo(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
362 monkeypatch.chdir(tmp_path)
363 r = runner.invoke(cli, ["code", "api-surface"])
364 assert r.exit_code != 0
365
366
367 # ---------------------------------------------------------------------------
368 # Integration: diff mode
369 # ---------------------------------------------------------------------------
370
371
372 @pytest.fixture
373 def diff_repo(repo: pathlib.Path) -> pathlib.Path:
374 """Three-commit repo that produces a clear diff with all change types."""
375 # Commit 1: baseline API
376 (repo / "billing.py").write_text(textwrap.dedent("""\
377 def compute_total(items):
378 return sum(items)
379
380 def apply_discount(total, pct):
381 return total * (1 - pct)
382
383 def _private_helper():
384 pass
385 """))
386 runner.invoke(cli, ["code", "add", "."])
387 r = runner.invoke(cli, ["commit", "-m", "Baseline API"])
388 assert r.exit_code == 0, r.output
389
390 # Commit 2: modified API
391 # - compute_total: signature changed (new param)
392 # - apply_discount: removed (breaking)
393 # - calculate_tax: added
394 (repo / "billing.py").write_text(textwrap.dedent("""\
395 def compute_total(items, currency="USD"):
396 return sum(items)
397
398 def calculate_tax(amount, rate):
399 return amount * rate
400
401 def _private_helper():
402 pass
403 """))
404 runner.invoke(cli, ["code", "add", "."])
405 r = runner.invoke(cli, ["commit", "-m", "Overhaul billing API"])
406 assert r.exit_code == 0, r.output
407 return repo
408
409
410 class TestApiSurfaceDiffMode:
411 def _commits(self, repo: pathlib.Path) -> list[str]:
412 from muse.core.commits import get_all_commits
413 return [
414 c.commit_id
415 for c in sorted(get_all_commits(repo), key=lambda c: c.committed_at)
416 ]
417
418 def test_diff_exits_zero(self, diff_repo: pathlib.Path) -> None:
419 commits = self._commits(diff_repo)
420 r = runner.invoke(cli, ["code", "api-surface", "--diff", commits[0]])
421 assert r.exit_code == 0, r.output
422
423 def test_diff_json_schema(self, diff_repo: pathlib.Path) -> None:
424 commits = self._commits(diff_repo)
425 r = runner.invoke(cli, ["code", "api-surface", "--diff", commits[0], "--json"])
426 assert r.exit_code == 0
427 data = json.loads(r.output)
428 for key in ("commit_id", "base_commit_id", "semver_impact", "stability_pct",
429 "breaking_count", "added", "removed", "changed"):
430 assert key in data, f"Missing key: {key}"
431
432 def test_diff_commit_ids_are_full_sha(self, diff_repo: pathlib.Path) -> None:
433 commits = self._commits(diff_repo)
434 r = runner.invoke(cli, ["code", "api-surface", "--diff", commits[0], "--json"])
435 assert r.exit_code == 0
436 data = json.loads(r.output)
437 assert data["commit_id"].startswith("sha256:")
438 assert data["base_commit_id"].startswith("sha256:")
439
440 def test_diff_detects_added_symbols(self, diff_repo: pathlib.Path) -> None:
441 commits = self._commits(diff_repo)
442 r = runner.invoke(cli, ["code", "api-surface", "--diff", commits[0], "--json"])
443 assert r.exit_code == 0
444 data = json.loads(r.output)
445 added_names = {s["name"] for s in data["added"]}
446 assert "calculate_tax" in added_names
447
448 def test_diff_detects_removed_symbols(self, diff_repo: pathlib.Path) -> None:
449 commits = self._commits(diff_repo)
450 r = runner.invoke(cli, ["code", "api-surface", "--diff", commits[0], "--json"])
451 assert r.exit_code == 0
452 data = json.loads(r.output)
453 removed_names = {s["name"] for s in data["removed"]}
454 assert "apply_discount" in removed_names
455
456 def test_diff_semver_impact_is_major(self, diff_repo: pathlib.Path) -> None:
457 commits = self._commits(diff_repo)
458 r = runner.invoke(cli, ["code", "api-surface", "--diff", commits[0], "--json"])
459 assert r.exit_code == 0
460 data = json.loads(r.output)
461 assert data["semver_impact"] == "MAJOR"
462
463 def test_diff_breaking_count_nonzero(self, diff_repo: pathlib.Path) -> None:
464 commits = self._commits(diff_repo)
465 r = runner.invoke(cli, ["code", "api-surface", "--diff", commits[0], "--json"])
466 assert r.exit_code == 0
467 data = json.loads(r.output)
468 assert data["breaking_count"] > 0
469
470 def test_diff_stability_pct_in_range(self, diff_repo: pathlib.Path) -> None:
471 commits = self._commits(diff_repo)
472 r = runner.invoke(cli, ["code", "api-surface", "--diff", commits[0], "--json"])
473 assert r.exit_code == 0
474 data = json.loads(r.output)
475 assert 0 <= data["stability_pct"] <= 100
476
477 def test_diff_changed_entries_have_breaking_field(self, diff_repo: pathlib.Path) -> None:
478 commits = self._commits(diff_repo)
479 r = runner.invoke(cli, ["code", "api-surface", "--diff", commits[0], "--json"])
480 assert r.exit_code == 0
481 data = json.loads(r.output)
482 for entry in data["changed"]:
483 assert "breaking" in entry
484 assert isinstance(entry["breaking"], bool)
485
486 def test_diff_count_only(self, diff_repo: pathlib.Path) -> None:
487 commits = self._commits(diff_repo)
488 r = runner.invoke(cli, ["code", "api-surface", "--diff", commits[0], "--count"])
489 assert r.exit_code == 0
490 assert r.output.strip().isdigit()
491
492 def test_diff_human_output_contains_sections(self, diff_repo: pathlib.Path) -> None:
493 commits = self._commits(diff_repo)
494 r = runner.invoke(cli, ["code", "api-surface", "--diff", commits[0]])
495 assert r.exit_code == 0
496 assert "Added" in r.output or "Removed" in r.output or "Changed" in r.output
497
498 def test_diff_human_output_shows_semver_impact(self, diff_repo: pathlib.Path) -> None:
499 commits = self._commits(diff_repo)
500 r = runner.invoke(cli, ["code", "api-surface", "--diff", commits[0]])
501 assert r.exit_code == 0
502 assert "semver impact:" in r.output
503
504 def test_diff_invalid_base_ref_rejected(self, diff_repo: pathlib.Path) -> None:
505 r = runner.invoke(cli, ["code", "api-surface", "--diff", "totally_nonexistent_ref"])
506 assert r.exit_code == 1
507
508 def test_breaking_flag_requires_diff(self, diff_repo: pathlib.Path) -> None:
509 r = runner.invoke(cli, ["code", "api-surface", "--breaking"])
510 assert r.exit_code == 1
511
512 def test_breaking_flag_filters_to_breaking_only(self, diff_repo: pathlib.Path) -> None:
513 commits = self._commits(diff_repo)
514 r = runner.invoke(cli, [
515 "code", "api-surface", "--diff", commits[0],
516 "--breaking", "--json",
517 ])
518 # Exit non-zero because breaking changes exist
519 data = json.loads(r.output)
520 # Added symbols should be filtered out in breaking-only mode
521 assert len(data["added"]) == 0
522 for entry in data["changed"]:
523 assert entry["breaking"] is True
524
525 def test_diff_file_filter(self, diff_repo: pathlib.Path) -> None:
526 commits = self._commits(diff_repo)
527 r = runner.invoke(cli, [
528 "code", "api-surface", "--diff", commits[0],
529 "--file", "billing.py", "--json",
530 ])
531 assert r.exit_code == 0
532 data = json.loads(r.output)
533 assert data["file_filter"] == "billing.py"
534
535 def test_no_changes_message(self, code_repo: pathlib.Path) -> None:
536 """Diffing a commit against itself shows no changes."""
537 from muse.core.commits import get_all_commits
538 commits_list = sorted(get_all_commits(code_repo), key=lambda c: c.committed_at)
539 head_id = commits_list[-1].commit_id
540 r = runner.invoke(cli, ["code", "api-surface", "--diff", head_id])
541 assert r.exit_code == 0
542 assert "No public API changes" in r.output
543
544
545 # ---------------------------------------------------------------------------
546 # Stress: performance with large snapshot and long history
547 # ---------------------------------------------------------------------------
548
549
550 class TestApiSurfaceStress:
551 def test_large_snapshot_completes_quickly(
552 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
553 ) -> None:
554 """50 Python files × 10 public functions — list mode under 5s.
555
556 Each file has unique content (module index embedded) so Muse's
557 content-addressed storage does not deduplicate symbol trees.
558 """
559 for i in range(50):
560 lines = "\n".join(
561 f"def public_fn_m{i}_f{j}(x, y):\n return x + y + {i * 100 + j}"
562 for j in range(10)
563 )
564 (repo / f"module_{i}.py").write_text(lines)
565 runner.invoke(cli, ["code", "add", "."])
566 r = runner.invoke(cli, ["commit", "-m", "Large snapshot"])
567 assert r.exit_code == 0
568
569 t0 = time.monotonic()
570 r = runner.invoke(cli, ["code", "api-surface", "--json"])
571 elapsed = time.monotonic() - t0
572
573 assert r.exit_code == 0
574 data = json.loads(r.output)
575 assert data["total"] == 500, (
576 f"Expected 500 public symbols (50 files × 10 fns), got {data['total']}"
577 )
578 assert elapsed < 5.0, f"api-surface took {elapsed:.1f}s — too slow"
579
580 def test_diff_large_snapshot_completes_quickly(
581 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
582 ) -> None:
583 """Diff across two 50-file snapshots — shared cache, under 6s."""
584 for i in range(50):
585 (repo / f"module_{i}.py").write_text(
586 "\n".join(f"def fn_m{i}_f{j}(x): return x + {i * 100 + j}" for j in range(10))
587 )
588 runner.invoke(cli, ["code", "add", "."])
589 r = runner.invoke(cli, ["commit", "-m", "Base snapshot"])
590 assert r.exit_code == 0
591
592 from muse.core.commits import get_all_commits
593 base_commits = sorted(get_all_commits(repo), key=lambda c: c.committed_at)
594 base_id = base_commits[0].commit_id
595
596 # Modify half the files (unique content per file, still)
597 for i in range(25):
598 (repo / f"module_{i}.py").write_text(
599 "\n".join(
600 f"def fn_m{i}_f{j}(x, y=0): return x + y + {i * 100 + j}"
601 for j in range(10)
602 )
603 )
604 runner.invoke(cli, ["code", "add", "."])
605 r = runner.invoke(cli, ["commit", "-m", "Modify 25 modules"])
606 assert r.exit_code == 0
607
608 t0 = time.monotonic()
609 r = runner.invoke(cli, ["code", "api-surface", "--diff", base_id, "--json"])
610 elapsed = time.monotonic() - t0
611
612 assert r.exit_code == 0
613 data = json.loads(r.output)
614 assert data["semver_impact"] in ("MAJOR", "MINOR", "PATCH", "NONE")
615 assert elapsed < 6.0, f"api-surface --diff took {elapsed:.1f}s — too slow"
616
617 def test_cache_reuse_second_call_faster(
618 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
619 ) -> None:
620 """Second invocation benefits from warm SymbolCache."""
621 for i in range(20):
622 (repo / f"mod_{i}.py").write_text(
623 "\n".join(f"def g_m{i}_f{j}(x): return x + {i * 50 + j}" for j in range(20))
624 )
625 runner.invoke(cli, ["code", "add", "."])
626 r = runner.invoke(cli, ["commit", "-m", "Warm cache test"])
627 assert r.exit_code == 0
628
629 # First call — cold cache
630 t0 = time.monotonic()
631 runner.invoke(cli, ["code", "api-surface", "--json"])
632 first = time.monotonic() - t0
633
634 # Second call — warm cache
635 t0 = time.monotonic()
636 runner.invoke(cli, ["code", "api-surface", "--json"])
637 second = time.monotonic() - t0
638
639 assert second < first * 0.8 or second < 0.5, (
640 f"Cache not helping: first={first:.2f}s second={second:.2f}s"
641 )
642
643
644 import argparse as _argparse
645
646
647 class TestRegisterFlags:
648 """Argparse registration tests for ``muse api-surface``."""
649
650 def _parse(self, *args: str) -> _argparse.Namespace:
651 from muse.cli.commands.api_surface import register
652 p = _argparse.ArgumentParser()
653 sub = p.add_subparsers()
654 register(sub)
655 return p.parse_args(["api-surface", *args])
656
657 def test_default_json_out_is_false(self) -> None:
658 ns = self._parse()
659 assert ns.json_out is False
660
661 def test_json_flag_sets_json_out(self) -> None:
662 ns = self._parse("--json")
663 assert ns.json_out is True
664
665 def test_j_shorthand_sets_json_out(self) -> None:
666 ns = self._parse("-j")
667 assert ns.json_out is True
668
669 def test_commit_flag(self) -> None:
670 ns = self._parse("--commit", "HEAD~1")
671 assert ns.ref == "HEAD~1"
672
673 def test_diff_default(self) -> None:
674 ns = self._parse()
675 assert ns.diff_ref is None
676
677 def test_language_flag(self) -> None:
678 ns = self._parse("--language", "Python")
679 assert ns.language == "Python"
680
681 def test_count_default(self) -> None:
682 ns = self._parse()
683 assert ns.count_only is False
File History 1 commit