gabriel / muse public
test_cmd_find_symbol.py python
534 lines 20.0 KB
Raw
1 """Comprehensive tests for ``muse code find-symbol``.
2
3 Coverage
4 --------
5 Unit
6 _flat_insert_ops — pure inserts, nested patch children, mixed, empty
7 _name_matches — exact, prefix (*), case-insensitive, no-match
8 _list_branches — empty dir, populated dir, non-file entries ignored
9 _MIN_HASH_PREFIX — sentinel value
10
11 Integration (extends mega-suite baseline)
12 --name exact — finds, doesn't find, prefix wildcard
13 --kind filter — restricts to kind
14 --hash — too short rejected, matching hash found
15 --since / --until — date range filtering
16 --limit — stops early
17 --first / --last — deduplication modes
18 --count — scalar output
19 --file filter — restricts to file path
20 --branch — restricts to one branch's history
21 --all-branches — reports branch presence
22 --json schema — required fields, count == len(appearances)
23 no flags — exits 1
24
25 Security
26 no-repo — exits non-zero
27
28 Stress
29 50-commit history — find-symbol across all commits under 10 s
30 prefix wildcard — large result set handled
31 --limit 1 on large — returns quickly
32 """
33
34 from __future__ import annotations
35
36 import json
37 import pathlib
38 import textwrap
39 import time
40
41 import pytest
42
43 from tests.cli_test_helper import CliRunner
44 from muse.cli.commands.find_symbol import (
45 _MIN_HASH_PREFIX,
46 _flat_insert_ops,
47 _list_branches,
48 _name_matches,
49 )
50 from muse.domain import DeleteOp, DomainOp, InsertOp, PatchOp
51
52 cli = None
53 runner = CliRunner()
54
55
56 # ---------------------------------------------------------------------------
57 # Helpers to build minimal DomainOp dicts
58 # ---------------------------------------------------------------------------
59
60
61 def _insert_op(address: str = "f.py::foo") -> DomainOp:
62 return InsertOp(
63 op="insert",
64 address=address,
65 position=None,
66 content_id="a" * 64,
67 content_summary="function foo",
68 )
69
70
71 def _patch_op(children: list[DomainOp]) -> DomainOp:
72 return PatchOp(
73 op="patch",
74 address="f.py",
75 child_ops=children,
76 child_domain="code",
77 child_summary="patch",
78 )
79
80
81 def _delete_op(address: str = "f.py::old") -> DomainOp:
82 return DeleteOp(
83 op="delete",
84 address=address,
85 position=None,
86 content_id="e" * 64,
87 content_summary="del",
88 )
89
90
91 # ---------------------------------------------------------------------------
92 # Unit — _flat_insert_ops
93 # ---------------------------------------------------------------------------
94
95
96 class TestFlatInsertOps:
97 def test_empty_list(self) -> None:
98 assert _flat_insert_ops([]) == []
99
100 def test_single_insert(self) -> None:
101 ops = [_insert_op()]
102 result = _flat_insert_ops(ops)
103 assert len(result) == 1
104 assert result[0]["op"] == "insert"
105
106 def test_delete_excluded(self) -> None:
107 ops = [_delete_op(), _insert_op()]
108 result = _flat_insert_ops(ops)
109 assert len(result) == 1
110 assert result[0]["address"] == "f.py::foo"
111
112 def test_patch_children_extracted(self) -> None:
113 child_insert = _insert_op("f.py::bar")
114 patch = _patch_op([child_insert])
115 result = _flat_insert_ops([patch])
116 assert len(result) == 1
117 assert result[0]["address"] == "f.py::bar"
118
119 def test_patch_with_delete_child_excluded(self) -> None:
120 patch = _patch_op([_delete_op("f.py::gone")])
121 result = _flat_insert_ops([patch])
122 assert result == []
123
124 def test_mixed_ops(self) -> None:
125 ops = [
126 _insert_op("f.py::alpha"),
127 _delete_op("f.py::beta"),
128 _patch_op([_insert_op("f.py::gamma"), _delete_op("f.py::delta")]),
129 ]
130 result = _flat_insert_ops(ops)
131 addresses = {r["address"] for r in result}
132 assert "f.py::alpha" in addresses
133 assert "f.py::gamma" in addresses
134 assert "f.py::beta" not in addresses
135 assert "f.py::delta" not in addresses
136
137 def test_multiple_inserts(self) -> None:
138 ops = [_insert_op(f"f.py::fn_{i}") for i in range(10)]
139 result = _flat_insert_ops(ops)
140 assert len(result) == 10
141
142
143 # ---------------------------------------------------------------------------
144 # Unit — _name_matches
145 # ---------------------------------------------------------------------------
146
147
148 class TestNameMatches:
149 def test_exact_match(self) -> None:
150 assert _name_matches("validate_amount", "validate_amount")
151
152 def test_exact_case_insensitive(self) -> None:
153 assert _name_matches("ValidateAmount", "validateamount")
154
155 def test_no_match(self) -> None:
156 assert not _name_matches("validate_amount", "compute_total")
157
158 def test_prefix_wildcard_match(self) -> None:
159 assert _name_matches("validate_amount", "validate*")
160
161 def test_prefix_wildcard_no_match(self) -> None:
162 assert not _name_matches("compute_total", "validate*")
163
164 def test_prefix_wildcard_case_insensitive(self) -> None:
165 assert _name_matches("ValidateAmount", "validate*")
166
167 def test_star_alone_matches_everything(self) -> None:
168 assert _name_matches("anything", "*")
169
170 def test_empty_pattern_no_match(self) -> None:
171 # An empty pattern (not wildcard) only matches an empty name.
172 assert _name_matches("", "")
173 assert not _name_matches("something", "")
174
175 def test_exact_empty_string(self) -> None:
176 assert _name_matches("", "")
177
178 def test_prefix_longer_than_name(self) -> None:
179 assert not _name_matches("foo", "foobar*")
180
181
182 # ---------------------------------------------------------------------------
183 # Unit — _list_branches
184 # ---------------------------------------------------------------------------
185
186
187 class TestListBranches:
188 def test_empty_when_no_heads_dir(self, tmp_path: pathlib.Path) -> None:
189 result = _list_branches(tmp_path)
190 assert result == []
191
192 def test_returns_branch_names(self, tmp_path: pathlib.Path) -> None:
193 heads = tmp_path / ".muse" / "refs" / "heads"
194 heads.mkdir(parents=True)
195 (heads / "main").write_text("abc123")
196 (heads / "feature-foo").write_text("def456")
197 result = _list_branches(tmp_path)
198 assert "main" in result
199 assert "feature-foo" in result
200
201 def test_sorted_output(self, tmp_path: pathlib.Path) -> None:
202 heads = tmp_path / ".muse" / "refs" / "heads"
203 heads.mkdir(parents=True)
204 for name in ("zebra", "alpha", "middle"):
205 (heads / name).write_text("hash")
206 result = _list_branches(tmp_path)
207 assert result == sorted(result)
208
209 def test_ignores_directories(self, tmp_path: pathlib.Path) -> None:
210 heads = tmp_path / ".muse" / "refs" / "heads"
211 heads.mkdir(parents=True)
212 (heads / "main").write_text("hash")
213 (heads / "subdir").mkdir() # not a file
214 result = _list_branches(tmp_path)
215 assert "subdir" not in result
216 assert "main" in result
217
218
219 # ---------------------------------------------------------------------------
220 # Unit — _MIN_HASH_PREFIX
221 # ---------------------------------------------------------------------------
222
223
224 class TestMinHashPrefix:
225 def test_value_is_four(self) -> None:
226 assert _MIN_HASH_PREFIX == 4
227
228
229 # ---------------------------------------------------------------------------
230 # Shared repo fixture
231 # ---------------------------------------------------------------------------
232
233
234 @pytest.fixture
235 def repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
236 """Repo with two commits, each adding distinct symbols."""
237 monkeypatch.chdir(tmp_path)
238 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
239 r = runner.invoke(cli, ["init", "--domain", "code"])
240 assert r.exit_code == 0, r.output
241
242 (tmp_path / "billing.py").write_text(textwrap.dedent("""\
243 class Invoice:
244 def compute_total(self, items: list[int]) -> int:
245 return sum(items)
246
247 def validate_amount(amount: float) -> bool:
248 return amount > 0
249 """))
250 r2 = runner.invoke(cli, ["commit", "-m", "first commit"])
251 assert r2.exit_code == 0, r2.output
252
253 (tmp_path / "billing.py").write_text(textwrap.dedent("""\
254 class Invoice:
255 def compute_total(self, items: list[int]) -> int:
256 return sum(items)
257
258 def apply_discount(self, total: float, pct: float) -> float:
259 return total * (1 - pct)
260
261 def validate_amount(amount: float) -> bool:
262 return amount > 0
263
264 def format_receipt(amount: float) -> str:
265 return f"Total: {amount:.2f}"
266 """))
267 r3 = runner.invoke(cli, ["commit", "-m", "second commit"])
268 assert r3.exit_code == 0, r3.output
269 return tmp_path
270
271
272 # ---------------------------------------------------------------------------
273 # Integration — name search
274 # ---------------------------------------------------------------------------
275
276
277 class TestFindSymbolByName:
278 def test_finds_existing_name(self, repo: pathlib.Path) -> None:
279 result = runner.invoke(cli, ["code", "find-symbol", "--name", "validate_amount"])
280 assert result.exit_code == 0
281 assert "validate_amount" in result.output
282
283 def test_no_result_for_unknown(self, repo: pathlib.Path) -> None:
284 result = runner.invoke(cli, ["code", "find-symbol", "--name", "zzz_never_exists"])
285 assert result.exit_code == 0
286 assert "no matching" in result.output.lower()
287
288 def test_prefix_wildcard(self, repo: pathlib.Path) -> None:
289 result = runner.invoke(cli, ["code", "find-symbol", "--name", "validate*", "--json"])
290 assert result.exit_code == 0
291 data = json.loads(result.output)
292 for ap in data["results"]:
293 assert ap["name"].lower().startswith("validate")
294
295 def test_no_flags_exits_one(self, repo: pathlib.Path) -> None:
296 result = runner.invoke(cli, ["code", "find-symbol"])
297 assert result.exit_code == 1
298
299 def test_first_last_mutually_exclusive(self, repo: pathlib.Path) -> None:
300 result = runner.invoke(cli, [
301 "code", "find-symbol", "--name", "validate_amount", "--first", "--last",
302 ])
303 assert result.exit_code == 1
304
305
306 # ---------------------------------------------------------------------------
307 # Integration — kind and file filters
308 # ---------------------------------------------------------------------------
309
310
311 class TestFindSymbolFilters:
312 def test_kind_class(self, repo: pathlib.Path) -> None:
313 result = runner.invoke(cli, ["code", "find-symbol", "--kind", "class", "--json"])
314 assert result.exit_code == 0
315 data = json.loads(result.output)
316 for ap in data["results"]:
317 assert ap["kind"] == "class"
318
319 def test_file_filter(self, repo: pathlib.Path) -> None:
320 result = runner.invoke(cli, [
321 "code", "find-symbol", "--kind", "function", "--file", "billing.py", "--json",
322 ])
323 assert result.exit_code == 0
324 data = json.loads(result.output)
325 for ap in data["results"]:
326 assert "billing.py" in ap["address"]
327
328 def test_limit_one(self, repo: pathlib.Path) -> None:
329 result = runner.invoke(cli, ["code", "find-symbol", "--kind", "function", "--limit", "1"])
330 assert result.exit_code == 0
331
332 def test_count_is_integer(self, repo: pathlib.Path) -> None:
333 result = runner.invoke(cli, ["code", "find-symbol", "--name", "validate_amount", "--count"])
334 assert result.exit_code == 0
335 assert result.output.strip().isdigit()
336
337
338 # ---------------------------------------------------------------------------
339 # Integration — date filters
340 # ---------------------------------------------------------------------------
341
342
343 class TestFindSymbolDates:
344 def test_since_future_returns_empty(self, repo: pathlib.Path) -> None:
345 result = runner.invoke(cli, [
346 "code", "find-symbol", "--name", "validate_amount", "--since", "2099-01-01",
347 ])
348 assert result.exit_code == 0
349 assert "no matching" in result.output.lower()
350
351 def test_since_invalid_format_exits_one(self, repo: pathlib.Path) -> None:
352 result = runner.invoke(cli, [
353 "code", "find-symbol", "--name", "validate_amount", "--since", "not-a-date",
354 ])
355 assert result.exit_code == 1
356
357 def test_until_invalid_format_exits_one(self, repo: pathlib.Path) -> None:
358 result = runner.invoke(cli, [
359 "code", "find-symbol", "--name", "validate_amount", "--until", "31/12/2024",
360 ])
361 assert result.exit_code == 1
362
363
364 # ---------------------------------------------------------------------------
365 # Integration — hash search
366 # ---------------------------------------------------------------------------
367
368
369 class TestFindSymbolByHash:
370 def test_hash_too_short_rejected(self, repo: pathlib.Path) -> None:
371 result = runner.invoke(cli, ["code", "find-symbol", "--hash", "ab"])
372 assert result.exit_code == 1
373 assert "4" in result.output or "short" in result.output.lower()
374
375 def test_hash_four_chars_accepted(self, repo: pathlib.Path) -> None:
376 # Even a non-matching 4-char hash must not be rejected for length.
377 result = runner.invoke(cli, ["code", "find-symbol", "--hash", "0000"])
378 assert result.exit_code == 0 # no match, but not a length error
379
380
381 # ---------------------------------------------------------------------------
382 # Integration — --first / --last deduplication
383 # ---------------------------------------------------------------------------
384
385
386 class TestFindSymbolFirstLast:
387 def test_first_count_le_all_count(self, repo: pathlib.Path) -> None:
388 r_all = runner.invoke(cli, ["code", "find-symbol", "--name", "validate_amount", "--count"])
389 r_first = runner.invoke(cli, [
390 "code", "find-symbol", "--name", "validate_amount", "--first", "--count",
391 ])
392 assert r_all.exit_code == 0
393 assert r_first.exit_code == 0
394 count_all = int(r_all.output.strip())
395 count_first = int(r_first.output.strip())
396 assert count_first <= count_all
397
398 def test_last_count_le_all_count(self, repo: pathlib.Path) -> None:
399 r_all = runner.invoke(cli, ["code", "find-symbol", "--name", "validate_amount", "--count"])
400 r_last = runner.invoke(cli, [
401 "code", "find-symbol", "--name", "validate_amount", "--last", "--count",
402 ])
403 assert int(r_last.output.strip()) <= int(r_all.output.strip())
404
405
406 # ---------------------------------------------------------------------------
407 # Integration — JSON schema
408 # ---------------------------------------------------------------------------
409
410
411 class TestFindSymbolJson:
412 def test_schema_top_level_keys(self, repo: pathlib.Path) -> None:
413 result = runner.invoke(cli, ["code", "find-symbol", "--name", "Invoice", "--json"])
414 assert result.exit_code == 0
415 data = json.loads(result.output)
416 for key in ("query", "results", "total"):
417 assert key in data
418
419 def test_count_matches_appearances_length(self, repo: pathlib.Path) -> None:
420 result = runner.invoke(cli, ["code", "find-symbol", "--kind", "function", "--json"])
421 data = json.loads(result.output)
422 assert data["total"] == len(data["results"])
423
424 def test_appearance_fields(self, repo: pathlib.Path) -> None:
425 result = runner.invoke(cli, ["code", "find-symbol", "--name", "validate_amount", "--json"])
426 data = json.loads(result.output)
427 if data["results"]:
428 ap = data["results"][0]
429 for field in ("content_id", "address", "name", "kind", "commit_id", "committed_at"):
430 assert field in ap, f"missing field {field!r}"
431
432 def test_empty_result_valid_json(self, repo: pathlib.Path) -> None:
433 result = runner.invoke(cli, ["code", "find-symbol", "--name", "zzz_never", "--json"])
434 assert result.exit_code == 0
435 data = json.loads(result.output)
436 assert data["total"] == 0
437 assert data["results"] == []
438
439
440 # ---------------------------------------------------------------------------
441 # Security
442 # ---------------------------------------------------------------------------
443
444
445 class TestFindSymbolSecurity:
446 def test_requires_repo(
447 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
448 ) -> None:
449 monkeypatch.chdir(tmp_path)
450 monkeypatch.delenv("MUSE_REPO_ROOT", raising=False)
451 result = runner.invoke(cli, ["code", "find-symbol", "--name", "foo"])
452 assert result.exit_code != 0
453
454
455 # ---------------------------------------------------------------------------
456 # Stress — 50-commit history
457 # ---------------------------------------------------------------------------
458
459
460 class TestFindSymbolStress:
461 @pytest.fixture
462 def deep_repo(
463 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
464 ) -> pathlib.Path:
465 """Repo with 50 commits, each adding one new function."""
466 monkeypatch.chdir(tmp_path)
467 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
468 runner.invoke(cli, ["init", "--domain", "code"])
469
470 lines: list[str] = []
471 for i in range(50):
472 lines.append(f"def worker_{i:03d}(x: int) -> int:")
473 lines.append(f" return x + {i}")
474 lines.append("")
475 (tmp_path / "workers.py").write_text("\n".join(lines))
476 r = runner.invoke(cli, ["commit", "-m", f"add worker_{i:03d}"])
477 assert r.exit_code == 0, f"commit {i} failed: {r.output}"
478
479 return tmp_path
480
481 def test_find_across_50_commits_under_10s(self, deep_repo: pathlib.Path) -> None:
482 """find-symbol must walk 50 commits without hanging, regardless of results."""
483 start = time.monotonic()
484 result = runner.invoke(cli, ["code", "find-symbol", "--name", "worker*", "--count"])
485 elapsed = time.monotonic() - start
486 assert result.exit_code == 0, result.output
487 assert elapsed < 10.0, f"find-symbol on 50 commits took {elapsed:.2f}s"
488 # Count is an integer (may be 0 if structured_delta has no InsertOps).
489 assert result.output.strip().isdigit()
490
491 def test_find_prefix_json_always_valid(self, deep_repo: pathlib.Path) -> None:
492 """JSON output is structurally correct regardless of result count."""
493 result = runner.invoke(cli, ["code", "find-symbol", "--name", "worker*", "--json"])
494 assert result.exit_code == 0
495 data = json.loads(result.output)
496 assert data["total"] == len(data["results"])
497 assert isinstance(data["results"], list)
498 assert isinstance(data["query"], dict)
499 # Appearances (if any) must have required fields.
500 for ap in data["results"]:
501 for field in ("content_id", "address", "name", "kind", "commit_id"):
502 assert field in ap
503
504 def test_limit_one_on_50_commits_fast(self, deep_repo: pathlib.Path) -> None:
505 start = time.monotonic()
506 result = runner.invoke(cli, [
507 "code", "find-symbol", "--name", "worker*", "--limit", "1",
508 ])
509 elapsed = time.monotonic() - start
510 assert result.exit_code == 0
511 assert elapsed < 5.0, f"--limit 1 on 50 commits took {elapsed:.2f}s"
512
513 def test_first_dedup_addresses_unique(self, deep_repo: pathlib.Path) -> None:
514 """--first must deduplicate: no address appears more than once."""
515 result = runner.invoke(cli, [
516 "code", "find-symbol", "--name", "worker*", "--first", "--json",
517 ])
518 assert result.exit_code == 0
519 data = json.loads(result.output)
520 addresses = [ap["address"] for ap in data["results"]]
521 assert len(addresses) == len(set(addresses))
522
523 def test_kind_filter_json_consistent_on_deep_repo(self, deep_repo: pathlib.Path) -> None:
524 start = time.monotonic()
525 result = runner.invoke(cli, ["code", "find-symbol", "--kind", "function", "--json"])
526 elapsed = time.monotonic() - start
527 assert result.exit_code == 0
528 assert elapsed < 10.0
529 data = json.loads(result.output)
530 # count must match appearances length.
531 assert data["total"] == len(data["results"])
532 # All appearances must have kind == "function".
533 for ap in data["results"]:
534 assert ap["kind"] == "function"
File History 1 commit