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