gabriel / muse public
test_api_surface.py python
630 lines 23.6 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 len(data["commit_id"]) == 64, "commit_id must be full SHA"
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 assert len(sym["content_id"]) == 64, f"Truncated content_id in {sym['address']}"
325
326 def test_count_only_is_integer(self, code_repo: pathlib.Path) -> None:
327 r = runner.invoke(cli, ["code", "api-surface", "--count"])
328 assert r.exit_code == 0
329 assert r.output.strip().isdigit()
330
331 def test_language_filter_python(self, code_repo: pathlib.Path) -> None:
332 r = runner.invoke(cli, ["code", "api-surface", "--language", "Python"])
333 assert r.exit_code == 0
334
335 def test_file_filter_restricts_output(self, code_repo: pathlib.Path) -> None:
336 r = runner.invoke(cli, ["code", "api-surface", "--json", "--file", "billing.py"])
337 assert r.exit_code == 0
338 data = json.loads(r.output)
339 for sym in data["results"]:
340 assert "billing.py" in sym["address"]
341
342 def test_file_filter_nonexistent_returns_empty(self, code_repo: pathlib.Path) -> None:
343 r = runner.invoke(cli, ["code", "api-surface", "--json", "--file", "nonexistent_xyz.py"])
344 assert r.exit_code == 0
345 data = json.loads(r.output)
346 assert data["total"] == 0
347
348 def test_no_commits_handled(self, repo: pathlib.Path) -> None:
349 r = runner.invoke(cli, ["code", "api-surface"])
350 assert r.exit_code in (0, 1)
351
352 def test_invalid_ref_rejected(self, code_repo: pathlib.Path) -> None:
353 r = runner.invoke(cli, ["code", "api-surface", "--commit", "deadbeef0000nonexistent"])
354 assert r.exit_code == 1
355
356 def test_requires_repo(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
357 monkeypatch.chdir(tmp_path)
358 r = runner.invoke(cli, ["code", "api-surface"])
359 assert r.exit_code != 0
360
361
362 # ---------------------------------------------------------------------------
363 # Integration: diff mode
364 # ---------------------------------------------------------------------------
365
366
367 @pytest.fixture
368 def diff_repo(repo: pathlib.Path) -> pathlib.Path:
369 """Three-commit repo that produces a clear diff with all change types."""
370 # Commit 1: baseline API
371 (repo / "billing.py").write_text(textwrap.dedent("""\
372 def compute_total(items):
373 return sum(items)
374
375 def apply_discount(total, pct):
376 return total * (1 - pct)
377
378 def _private_helper():
379 pass
380 """))
381 r = runner.invoke(cli, ["commit", "-m", "Baseline API"])
382 assert r.exit_code == 0, r.output
383
384 # Commit 2: modified API
385 # - compute_total: signature changed (new param)
386 # - apply_discount: removed (breaking)
387 # - calculate_tax: added
388 (repo / "billing.py").write_text(textwrap.dedent("""\
389 def compute_total(items, currency="USD"):
390 return sum(items)
391
392 def calculate_tax(amount, rate):
393 return amount * rate
394
395 def _private_helper():
396 pass
397 """))
398 r = runner.invoke(cli, ["commit", "-m", "Overhaul billing API"])
399 assert r.exit_code == 0, r.output
400 return repo
401
402
403 class TestApiSurfaceDiffMode:
404 def _commits(self, repo: pathlib.Path) -> list[str]:
405 from muse.core.store import get_all_commits
406 return [
407 c.commit_id
408 for c in sorted(get_all_commits(repo), key=lambda c: c.committed_at)
409 ]
410
411 def test_diff_exits_zero(self, diff_repo: pathlib.Path) -> None:
412 commits = self._commits(diff_repo)
413 r = runner.invoke(cli, ["code", "api-surface", "--diff", commits[0]])
414 assert r.exit_code == 0, r.output
415
416 def test_diff_json_schema(self, diff_repo: pathlib.Path) -> None:
417 commits = self._commits(diff_repo)
418 r = runner.invoke(cli, ["code", "api-surface", "--diff", commits[0], "--json"])
419 assert r.exit_code == 0
420 data = json.loads(r.output)
421 for key in ("commit_id", "base_commit_id", "semver_impact", "stability_pct",
422 "breaking_count", "added", "removed", "changed"):
423 assert key in data, f"Missing key: {key}"
424
425 def test_diff_commit_ids_are_full_sha(self, diff_repo: pathlib.Path) -> None:
426 commits = self._commits(diff_repo)
427 r = runner.invoke(cli, ["code", "api-surface", "--diff", commits[0], "--json"])
428 assert r.exit_code == 0
429 data = json.loads(r.output)
430 assert len(data["commit_id"]) == 64
431 assert len(data["base_commit_id"]) == 64
432
433 def test_diff_detects_added_symbols(self, diff_repo: pathlib.Path) -> None:
434 commits = self._commits(diff_repo)
435 r = runner.invoke(cli, ["code", "api-surface", "--diff", commits[0], "--json"])
436 assert r.exit_code == 0
437 data = json.loads(r.output)
438 added_names = {s["name"] for s in data["added"]}
439 assert "calculate_tax" in added_names
440
441 def test_diff_detects_removed_symbols(self, diff_repo: pathlib.Path) -> None:
442 commits = self._commits(diff_repo)
443 r = runner.invoke(cli, ["code", "api-surface", "--diff", commits[0], "--json"])
444 assert r.exit_code == 0
445 data = json.loads(r.output)
446 removed_names = {s["name"] for s in data["removed"]}
447 assert "apply_discount" in removed_names
448
449 def test_diff_semver_impact_is_major(self, diff_repo: pathlib.Path) -> None:
450 commits = self._commits(diff_repo)
451 r = runner.invoke(cli, ["code", "api-surface", "--diff", commits[0], "--json"])
452 assert r.exit_code == 0
453 data = json.loads(r.output)
454 assert data["semver_impact"] == "MAJOR"
455
456 def test_diff_breaking_count_nonzero(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["breaking_count"] > 0
462
463 def test_diff_stability_pct_in_range(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 0 <= data["stability_pct"] <= 100
469
470 def test_diff_changed_entries_have_breaking_field(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 for entry in data["changed"]:
476 assert "breaking" in entry
477 assert isinstance(entry["breaking"], bool)
478
479 def test_diff_count_only(self, diff_repo: pathlib.Path) -> None:
480 commits = self._commits(diff_repo)
481 r = runner.invoke(cli, ["code", "api-surface", "--diff", commits[0], "--count"])
482 assert r.exit_code == 0
483 assert r.output.strip().isdigit()
484
485 def test_diff_human_output_contains_sections(self, diff_repo: pathlib.Path) -> None:
486 commits = self._commits(diff_repo)
487 r = runner.invoke(cli, ["code", "api-surface", "--diff", commits[0]])
488 assert r.exit_code == 0
489 assert "Added" in r.output or "Removed" in r.output or "Changed" in r.output
490
491 def test_diff_human_output_shows_semver_impact(self, diff_repo: pathlib.Path) -> None:
492 commits = self._commits(diff_repo)
493 r = runner.invoke(cli, ["code", "api-surface", "--diff", commits[0]])
494 assert r.exit_code == 0
495 assert "semver impact:" in r.output
496
497 def test_diff_invalid_base_ref_rejected(self, diff_repo: pathlib.Path) -> None:
498 r = runner.invoke(cli, ["code", "api-surface", "--diff", "totally_nonexistent_ref"])
499 assert r.exit_code == 1
500
501 def test_breaking_flag_requires_diff(self, diff_repo: pathlib.Path) -> None:
502 r = runner.invoke(cli, ["code", "api-surface", "--breaking"])
503 assert r.exit_code == 1
504
505 def test_breaking_flag_filters_to_breaking_only(self, diff_repo: pathlib.Path) -> None:
506 commits = self._commits(diff_repo)
507 r = runner.invoke(cli, [
508 "code", "api-surface", "--diff", commits[0],
509 "--breaking", "--json",
510 ])
511 # Exit non-zero because breaking changes exist
512 data = json.loads(r.output)
513 # Added symbols should be filtered out in breaking-only mode
514 assert len(data["added"]) == 0
515 for entry in data["changed"]:
516 assert entry["breaking"] is True
517
518 def test_diff_file_filter(self, diff_repo: pathlib.Path) -> None:
519 commits = self._commits(diff_repo)
520 r = runner.invoke(cli, [
521 "code", "api-surface", "--diff", commits[0],
522 "--file", "billing.py", "--json",
523 ])
524 assert r.exit_code == 0
525 data = json.loads(r.output)
526 assert data["file_filter"] == "billing.py"
527
528 def test_no_changes_message(self, code_repo: pathlib.Path) -> None:
529 """Diffing a commit against itself shows no changes."""
530 from muse.core.store import get_all_commits
531 commits_list = sorted(get_all_commits(code_repo), key=lambda c: c.committed_at)
532 head_id = commits_list[-1].commit_id
533 r = runner.invoke(cli, ["code", "api-surface", "--diff", head_id])
534 assert r.exit_code == 0
535 assert "No public API changes" in r.output
536
537
538 # ---------------------------------------------------------------------------
539 # Stress: performance with large snapshot and long history
540 # ---------------------------------------------------------------------------
541
542
543 class TestApiSurfaceStress:
544 def test_large_snapshot_completes_quickly(
545 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
546 ) -> None:
547 """50 Python files × 10 public functions — list mode under 5s.
548
549 Each file has unique content (module index embedded) so Muse's
550 content-addressed storage does not deduplicate symbol trees.
551 """
552 for i in range(50):
553 lines = "\n".join(
554 f"def public_fn_m{i}_f{j}(x, y):\n return x + y + {i * 100 + j}"
555 for j in range(10)
556 )
557 (repo / f"module_{i}.py").write_text(lines)
558 r = runner.invoke(cli, ["commit", "-m", "Large snapshot"])
559 assert r.exit_code == 0
560
561 t0 = time.monotonic()
562 r = runner.invoke(cli, ["code", "api-surface", "--json"])
563 elapsed = time.monotonic() - t0
564
565 assert r.exit_code == 0
566 data = json.loads(r.output)
567 assert data["total"] == 500, (
568 f"Expected 500 public symbols (50 files × 10 fns), got {data['total']}"
569 )
570 assert elapsed < 5.0, f"api-surface took {elapsed:.1f}s — too slow"
571
572 def test_diff_large_snapshot_completes_quickly(
573 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
574 ) -> None:
575 """Diff across two 50-file snapshots — shared cache, under 6s."""
576 for i in range(50):
577 (repo / f"module_{i}.py").write_text(
578 "\n".join(f"def fn_m{i}_f{j}(x): return x + {i * 100 + j}" for j in range(10))
579 )
580 r = runner.invoke(cli, ["commit", "-m", "Base snapshot"])
581 assert r.exit_code == 0
582
583 from muse.core.store import get_all_commits
584 base_commits = sorted(get_all_commits(repo), key=lambda c: c.committed_at)
585 base_id = base_commits[0].commit_id
586
587 # Modify half the files (unique content per file, still)
588 for i in range(25):
589 (repo / f"module_{i}.py").write_text(
590 "\n".join(
591 f"def fn_m{i}_f{j}(x, y=0): return x + y + {i * 100 + j}"
592 for j in range(10)
593 )
594 )
595 r = runner.invoke(cli, ["commit", "-m", "Modify 25 modules"])
596 assert r.exit_code == 0
597
598 t0 = time.monotonic()
599 r = runner.invoke(cli, ["code", "api-surface", "--diff", base_id, "--json"])
600 elapsed = time.monotonic() - t0
601
602 assert r.exit_code == 0
603 data = json.loads(r.output)
604 assert data["semver_impact"] in ("MAJOR", "MINOR", "PATCH", "NONE")
605 assert elapsed < 6.0, f"api-surface --diff took {elapsed:.1f}s — too slow"
606
607 def test_cache_reuse_second_call_faster(
608 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
609 ) -> None:
610 """Second invocation benefits from warm SymbolCache."""
611 for i in range(20):
612 (repo / f"mod_{i}.py").write_text(
613 "\n".join(f"def g_m{i}_f{j}(x): return x + {i * 50 + j}" for j in range(20))
614 )
615 r = runner.invoke(cli, ["commit", "-m", "Warm cache test"])
616 assert r.exit_code == 0
617
618 # First call — cold cache
619 t0 = time.monotonic()
620 runner.invoke(cli, ["code", "api-surface", "--json"])
621 first = time.monotonic() - t0
622
623 # Second call — warm cache
624 t0 = time.monotonic()
625 runner.invoke(cli, ["code", "api-surface", "--json"])
626 second = time.monotonic() - t0
627
628 assert second < first * 0.8 or second < 0.5, (
629 f"Cache not helping: first={first:.2f}s second={second:.2f}s"
630 )
File History 1 commit