gabriel / muse public
test_cmd_cat.py python
643 lines 25.3 KB
Raw
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 131 days ago
1 """Comprehensive tests for ``muse code cat``.
2
3 Coverage
4 --------
5 Unit
6 _extract_source — basic slice, context lines, unicode, binary-safe
7 _format_line_numbers — numbering, width padding, first-line offset
8 _resolve_symbol — qualified match, bare-name match, ambiguous, missing
9 _get_file_bytes — workdir read, object-store fallback, not-in-manifest
10
11 Integration
12 cat ADDRESS — found, missing, no "::", JSON output
13 cat --all — all symbols in file, kind filter
14 cat --at REF — historical snapshot
15 cat --context N — surrounding lines appear
16 cat --line-numbers — line numbers prefix
17 cat --json — schema, errors list, unicode
18 cat multiple addresses — batch lookup, partial errors
19 cat untracked file — exits 1
20
21 Security
22 sanitize_display — control chars in address do not crash
23 missing repo — exits non-zero outside repo
24
25 Stress
26 file with 200 symbols — --all completes in < 5 s
27 50 addresses in one call — batch under 3 s
28 """
29
30 from __future__ import annotations
31
32 import json
33 import pathlib
34 import textwrap
35 import time
36
37 import pytest
38
39 from typing import Literal
40
41 from tests.cli_test_helper import CliRunner
42 from muse.cli.commands.cat import (
43 _FileError,
44 _extract_source,
45 _format_line_numbers,
46 _get_file_bytes,
47 _resolve_symbol,
48 )
49 from muse.plugins.code.ast_parser import SymbolRecord, SymbolKind
50 from muse.core._types import long_id
51
52 cli = None
53 runner = CliRunner()
54
55
56 # ---------------------------------------------------------------------------
57 # Helpers
58 # ---------------------------------------------------------------------------
59
60
61 def _make_record(
62 qualified_name: str,
63 name: str,
64 lineno: int,
65 end_lineno: int,
66 kind: SymbolKind = "function",
67 ) -> SymbolRecord:
68 return SymbolRecord(
69 name=name,
70 qualified_name=qualified_name,
71 kind=kind,
72 lineno=lineno,
73 end_lineno=end_lineno,
74 content_id="a" * 64,
75 body_hash="b" * 64,
76 signature_id="c" * 64,
77 metadata_id="",
78 canonical_key=f"mod.py###{kind}#{name}#{lineno}",
79 )
80
81
82 # ---------------------------------------------------------------------------
83 # Shared repo fixture
84 # ---------------------------------------------------------------------------
85
86
87 @pytest.fixture
88 def repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
89 monkeypatch.chdir(tmp_path)
90 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
91 r = runner.invoke(cli, ["init", "--domain", "code"])
92 assert r.exit_code == 0, r.output
93
94 (tmp_path / "billing.py").write_text(textwrap.dedent("""\
95 class Invoice:
96 def compute_total(self, items: list[int]) -> int:
97 return sum(items)
98
99 def apply_discount(self, total: float, pct: float) -> float:
100 return total * (1 - pct)
101
102 def validate_amount(amount: float) -> bool:
103 return amount > 0
104
105 def format_receipt(amount: float) -> str:
106 return f"Total: {amount:.2f}"
107 """))
108
109 r2 = runner.invoke(cli, ["commit", "-m", "initial"])
110 assert r2.exit_code == 0, r2.output
111 return tmp_path
112
113
114 # ---------------------------------------------------------------------------
115 # Unit — _extract_source
116 # ---------------------------------------------------------------------------
117
118
119 class TestExtractSource:
120 _SOURCE = b"line1\nline2\nline3\nline4\nline5\n"
121
122 def test_basic_slice(self) -> None:
123 result = _extract_source(self._SOURCE, lineno=2, end_lineno=3)
124 assert result == "line2\nline3"
125
126 def test_single_line(self) -> None:
127 result = _extract_source(self._SOURCE, lineno=1, end_lineno=1)
128 assert result == "line1"
129
130 def test_context_before(self) -> None:
131 result = _extract_source(self._SOURCE, lineno=3, end_lineno=3, context=1)
132 assert "line2" in result
133 assert "line3" in result
134
135 def test_context_after(self) -> None:
136 result = _extract_source(self._SOURCE, lineno=3, end_lineno=3, context=1)
137 assert "line4" in result
138
139 def test_context_clamps_at_start(self) -> None:
140 # Asking for 10 lines of context before line 1 must not go negative.
141 result = _extract_source(self._SOURCE, lineno=1, end_lineno=1, context=10)
142 assert "line1" in result
143
144 def test_context_clamps_at_end(self) -> None:
145 result = _extract_source(self._SOURCE, lineno=5, end_lineno=5, context=10)
146 assert "line5" in result
147
148 def test_unicode_round_trips(self) -> None:
149 src = "def café() -> str:\n return 'café'\n".encode()
150 result = _extract_source(src, lineno=1, end_lineno=2)
151 assert "café" in result
152
153 def test_binary_errors_replaced(self) -> None:
154 src = b"def foo():\n x = \xff\xfe\n"
155 result = _extract_source(src, lineno=1, end_lineno=2)
156 assert "foo" in result # must not raise
157
158
159 # ---------------------------------------------------------------------------
160 # Unit — _format_line_numbers
161 # ---------------------------------------------------------------------------
162
163
164 class TestFormatLineNumbers:
165 def test_single_line_numbered(self) -> None:
166 result = _format_line_numbers("hello", start_lineno=5)
167 assert result.startswith("5")
168 assert "hello" in result
169
170 def test_multiline_numbered(self) -> None:
171 source = "a\nb\nc"
172 result = _format_line_numbers(source, start_lineno=1)
173 lines = result.splitlines()
174 assert len(lines) == 3
175 assert lines[0].startswith("1")
176 assert lines[2].startswith("3")
177
178 def test_width_pads_for_large_line_numbers(self) -> None:
179 # 100 lines → width=3; the separator " " appears at offset 3 for every line.
180 source = "\n".join(f"line_{i}" for i in range(100))
181 result = _format_line_numbers(source, start_lineno=1)
182 expected_width = len(str(100)) # 3
183 for line in result.splitlines():
184 sep = line[expected_width : expected_width + 2]
185 assert sep == " ", f"separator not at col {expected_width} in {line!r}"
186
187 def test_offset_start_lineno(self) -> None:
188 result = _format_line_numbers("hello", start_lineno=42)
189 assert result.startswith("42")
190
191
192 # ---------------------------------------------------------------------------
193 # Unit — _resolve_symbol
194 # ---------------------------------------------------------------------------
195
196
197 class TestResolveSymbol:
198 def test_qualified_name_match(self) -> None:
199 tree = {
200 "mod.py::MyClass.my_method": _make_record("MyClass.my_method", "my_method", 5, 7),
201 }
202 record, err = _resolve_symbol(tree, "MyClass.my_method", "mod.py")
203 assert record is not None
204 assert err == ""
205
206 def test_bare_name_unambiguous(self) -> None:
207 tree = {
208 "mod.py::my_func": _make_record("my_func", "my_func", 1, 3),
209 }
210 record, err = _resolve_symbol(tree, "my_func", "mod.py")
211 assert record is not None
212
213 def test_bare_name_ambiguous_returns_error(self) -> None:
214 tree = {
215 "mod.py::A.validate": _make_record("A.validate", "validate", 1, 2),
216 "mod.py::B.validate": _make_record("B.validate", "validate", 5, 6),
217 }
218 record, err = _resolve_symbol(tree, "validate", "mod.py")
219 assert record is None
220 assert "ambiguous" in err.lower() or "qualify" in err.lower()
221
222 def test_not_found_returns_error_message(self) -> None:
223 tree = {
224 "mod.py::existing": _make_record("existing", "existing", 1, 3),
225 }
226 record, err = _resolve_symbol(tree, "missing_func", "mod.py")
227 assert record is None
228 assert "not found" in err.lower() or "missing_func" in err
229
230 def test_empty_tree_returns_error(self) -> None:
231 record, err = _resolve_symbol({}, "anything", "mod.py")
232 assert record is None
233 assert len(err) > 0
234
235 def test_import_symbols_excluded_from_suggestions(self) -> None:
236 """Import pseudo-symbols must not show up as 'available' options."""
237 tree = {
238 "mod.py::import::os": _make_record("import::os", "os", 1, 1, kind="import"),
239 }
240 record, err = _resolve_symbol(tree, "missing", "mod.py")
241 assert record is None
242 assert "import::os" not in err
243
244
245 # ---------------------------------------------------------------------------
246 # Integration — basic address lookup
247 # ---------------------------------------------------------------------------
248
249
250 class TestCatBasic:
251 def test_finds_top_level_function(self, repo: pathlib.Path) -> None:
252 result = runner.invoke(cli, ["code", "cat", "billing.py::validate_amount"])
253 assert result.exit_code == 0, result.output
254 assert "validate_amount" in result.output
255
256 def test_shows_function_body(self, repo: pathlib.Path) -> None:
257 result = runner.invoke(cli, ["code", "cat", "billing.py::validate_amount"])
258 assert result.exit_code == 0
259 assert "amount > 0" in result.output
260
261 def test_finds_method(self, repo: pathlib.Path) -> None:
262 result = runner.invoke(cli, ["code", "cat", "billing.py::Invoice.compute_total"])
263 assert result.exit_code == 0
264 assert "compute_total" in result.output
265
266 def test_missing_symbol_exits_one(self, repo: pathlib.Path) -> None:
267 result = runner.invoke(cli, ["code", "cat", "billing.py::zzz_nonexistent"])
268 assert result.exit_code == 1
269
270 def test_no_separator_exits_one(self, repo: pathlib.Path) -> None:
271 result = runner.invoke(cli, ["code", "cat", "billing.py"])
272 assert result.exit_code == 1
273
274 def test_untracked_file_exits_one(self, repo: pathlib.Path) -> None:
275 result = runner.invoke(cli, ["code", "cat", "nowhere.py::foo"])
276 assert result.exit_code == 1
277
278
279 # ---------------------------------------------------------------------------
280 # Integration — --all mode
281 # ---------------------------------------------------------------------------
282
283
284 class TestCatFileFlag:
285 """--file <path> is a convenience alias for <path> --all."""
286
287 def test_file_flag_prints_all_symbols(self, repo: pathlib.Path) -> None:
288 result = runner.invoke(cli, ["code", "cat", "--file", "billing.py"])
289 assert result.exit_code == 0
290 assert "validate_amount" in result.output
291 assert "format_receipt" in result.output
292 assert "compute_total" in result.output
293
294 def test_file_flag_accepts_kind_filter(self, repo: pathlib.Path) -> None:
295 result = runner.invoke(cli, ["code", "cat", "--file", "billing.py", "--kind", "function"])
296 assert result.exit_code == 0
297 assert "validate_amount" in result.output
298
299 def test_file_flag_json_output(self, repo: pathlib.Path) -> None:
300 result = runner.invoke(cli, ["code", "cat", "--file", "billing.py", "--json"])
301 assert result.exit_code == 0
302 data = json.loads(result.output)
303 assert "results" in data
304 names = [r["symbol"] for r in data["results"]]
305 assert "validate_amount" in names
306
307 def test_file_flag_untracked_file_errors(self, repo: pathlib.Path) -> None:
308 result = runner.invoke(cli, ["code", "cat", "--file", "missing.py"])
309 assert result.exit_code != 0
310
311
312 class TestCatAll:
313 def test_all_prints_every_non_import_symbol(self, repo: pathlib.Path) -> None:
314 result = runner.invoke(cli, ["code", "cat", "--all", "billing.py"])
315 assert result.exit_code == 0
316 assert "validate_amount" in result.output
317 assert "format_receipt" in result.output
318 assert "compute_total" in result.output
319
320 def test_all_kind_filter_functions_only(self, repo: pathlib.Path) -> None:
321 result = runner.invoke(cli, ["code", "cat", "--all", "--kind", "function", "billing.py"])
322 assert result.exit_code == 0
323 assert "validate_amount" in result.output
324 # Classes should not appear as their own block (only functions).
325
326 def test_all_untracked_file_skips_gracefully(self, repo: pathlib.Path) -> None:
327 result = runner.invoke(cli, ["code", "cat", "--all", "missing.py"])
328 # Should exit with error since file not in manifest.
329 assert result.exit_code != 0 or "not tracked" in result.output.lower()
330
331
332 # ---------------------------------------------------------------------------
333 # Integration — --line-numbers and --context
334 # ---------------------------------------------------------------------------
335
336
337 class TestCatLineNumbers:
338 def test_line_numbers_flag(self, repo: pathlib.Path) -> None:
339 result = runner.invoke(cli, [
340 "code", "cat", "--line-numbers", "billing.py::validate_amount",
341 ])
342 assert result.exit_code == 0
343 # Some line in the output should start with a digit.
344 output_lines = result.output.splitlines()
345 has_number = any(line.strip() and line.strip()[0].isdigit() for line in output_lines)
346 assert has_number
347
348 def test_context_includes_surrounding_lines(self, repo: pathlib.Path) -> None:
349 result = runner.invoke(cli, [
350 "code", "cat", "--context", "2", "billing.py::validate_amount",
351 ])
352 assert result.exit_code == 0
353 # With context=2 the preceding lines of the file should appear.
354 assert len(result.output.splitlines()) > 2
355
356
357 # ---------------------------------------------------------------------------
358 # Integration — --json
359 # ---------------------------------------------------------------------------
360
361
362 class TestCatJson:
363 def test_json_schema(self, repo: pathlib.Path) -> None:
364 result = runner.invoke(cli, ["code", "cat", "--json", "billing.py::validate_amount"])
365 assert result.exit_code == 0, result.output
366 data = json.loads(result.output)
367 assert "results" in data
368 assert "errors" in data
369 assert len(data["results"]) == 1
370
371 def test_json_result_fields(self, repo: pathlib.Path) -> None:
372 result = runner.invoke(cli, ["code", "cat", "--json", "billing.py::validate_amount"])
373 data = json.loads(result.output)
374 r = data["results"][0]
375 for field in ("address", "source", "kind", "lineno", "end_lineno"):
376 assert field in r, f"missing field {field!r}"
377
378 def test_json_missing_symbol_in_errors(self, repo: pathlib.Path) -> None:
379 result = runner.invoke(cli, ["code", "cat", "--json", "billing.py::zzz_missing"])
380 data = json.loads(result.output)
381 assert len(data["errors"]) == 1
382 assert "zzz_missing" in data["errors"][0].get("error", "")
383
384 def test_json_multiple_results(self, repo: pathlib.Path) -> None:
385 result = runner.invoke(cli, [
386 "code", "cat", "--json",
387 "billing.py::validate_amount",
388 "billing.py::format_receipt",
389 ])
390 data = json.loads(result.output)
391 assert len(data["results"]) == 2
392
393 def test_json_partial_error(self, repo: pathlib.Path) -> None:
394 result = runner.invoke(cli, [
395 "code", "cat", "--json",
396 "billing.py::validate_amount",
397 "billing.py::zzz_missing",
398 ])
399 data = json.loads(result.output)
400 assert len(data["results"]) == 1
401 assert len(data["errors"]) == 1
402
403 def test_json_has_duration_ms(self, repo: pathlib.Path) -> None:
404 result = runner.invoke(cli, ["code", "cat", "--json", "billing.py::validate_amount"])
405 assert result.exit_code == 0
406 data = json.loads(result.output)
407 assert "duration_ms" in data
408 assert isinstance(data["duration_ms"], float)
409 assert data["duration_ms"] >= 0.0
410
411 def test_json_has_source_ref(self, repo: pathlib.Path) -> None:
412 result = runner.invoke(cli, ["code", "cat", "--json", "billing.py::validate_amount"])
413 data = json.loads(result.output)
414 assert "source_ref" in data
415 assert data["source_ref"] == "working tree"
416
417 def test_format_json_flag(self, repo: pathlib.Path) -> None:
418 """--json flag produces JSON output."""
419 result = runner.invoke(cli, [
420 "code", "cat", "--json", "billing.py::validate_amount",
421 ])
422 assert result.exit_code == 0
423 data = json.loads(result.output)
424 assert "results" in data
425
426 def test_format_text_is_default(self, repo: pathlib.Path) -> None:
427 """Default (no flag) produces text output, not JSON."""
428 result = runner.invoke(cli, ["code", "cat", "billing.py::validate_amount"])
429 assert result.exit_code == 0
430 # Text output starts with a '#' header, not a JSON brace.
431 assert result.output.strip().startswith("#")
432
433 def test_json_error_has_error_code(self, repo: pathlib.Path) -> None:
434 """Every error entry in JSON output must have an error_code field."""
435 result = runner.invoke(cli, [
436 "code", "cat", "--json",
437 "billing.py::zzz_missing",
438 "nowhere.py::foo",
439 ])
440 data = json.loads(result.output)
441 for err in data["errors"]:
442 assert "error_code" in err, f"error_code missing from {err}"
443
444
445 # ---------------------------------------------------------------------------
446 # Integration — --at historical snapshot
447 # ---------------------------------------------------------------------------
448
449
450 class TestCatAtRef:
451 def test_at_head_works(self, repo: pathlib.Path) -> None:
452 result = runner.invoke(cli, [
453 "code", "cat", "--at", "HEAD", "billing.py::validate_amount",
454 ])
455 assert result.exit_code == 0, result.output
456 assert "validate_amount" in result.output
457
458 def test_at_bad_ref_exits_one(self, repo: pathlib.Path) -> None:
459 result = runner.invoke(cli, [
460 "code", "cat", "--at", "zzz_bad_ref_xyz",
461 "billing.py::validate_amount",
462 ])
463 assert result.exit_code == 1
464
465
466 # ---------------------------------------------------------------------------
467 # Security
468 # ---------------------------------------------------------------------------
469
470
471 class TestCatSecurity:
472 def test_requires_repo(
473 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
474 ) -> None:
475 monkeypatch.chdir(tmp_path)
476 monkeypatch.delenv("MUSE_REPO_ROOT", raising=False)
477 result = runner.invoke(cli, ["code", "cat", "billing.py::foo"])
478 assert result.exit_code != 0
479
480 def test_control_chars_in_address_do_not_crash(self, repo: pathlib.Path) -> None:
481 result = runner.invoke(cli, ["code", "cat", "billing.py::foo\x01bar"])
482 assert result.exit_code in (0, 1) # must not raise unhandled exception
483
484 def test_symlink_workdir_rejected(
485 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
486 ) -> None:
487 """A tracked file that is actually a symlink must be rejected."""
488 # Create a real file so the manifest knows about it, then replace with a symlink.
489 real = repo / "billing.py"
490 link = repo / "link.py"
491 link.symlink_to(real)
492 # Inject the symlink path into a fake manifest and call _get_file_bytes directly.
493 fake_manifest = {"link.py": "a" * 64}
494 with pytest.raises(_FileError) as exc_info:
495 _get_file_bytes(repo, "link.py", fake_manifest, source_is_workdir=True)
496 assert exc_info.value.code == "SYMLINK_REJECTED"
497
498 def test_path_traversal_rejected(
499 self, repo: pathlib.Path, monkeypatch: pytest.MonkeyPatch
500 ) -> None:
501 """A manifest entry with '..' that escapes the repo must be rejected."""
502 fake_manifest = {"../outside.py": "a" * 64}
503 with pytest.raises(_FileError) as exc_info:
504 _get_file_bytes(repo, "../outside.py", fake_manifest, source_is_workdir=True)
505 assert exc_info.value.code in ("PATH_TRAVERSAL", "FILE_NOT_TRACKED")
506
507 def test_blob_not_found_gives_precise_error_code(
508 self, repo: pathlib.Path
509 ) -> None:
510 """Missing blob raises _FileError with BLOB_NOT_FOUND, not generic exit."""
511 fake_manifest = {"billing.py": long_id("0" * 64)} # blob that doesn't exist in store
512 with pytest.raises(_FileError) as exc_info:
513 _get_file_bytes(repo, "billing.py", fake_manifest, source_is_workdir=False)
514 assert exc_info.value.code == "BLOB_NOT_FOUND"
515
516 def test_symlink_in_json_gives_error_code(self, repo: pathlib.Path) -> None:
517 """Symlink rejection surfaces as a JSON error, not a crash."""
518 link = repo / "symlink_billing.py"
519 link.symlink_to(repo / "billing.py")
520 # Commit so symlink_billing.py appears in the manifest (won't — symlinks
521 # are not tracked by the code plugin, so we test via _all_ on an untracked path).
522 result = runner.invoke(cli, ["code", "cat", "--json", "symlink_billing.py::foo"])
523 # Either exit 0 with an error in the errors list, or exit 1 — never a crash.
524 assert result.exit_code in (0, 1)
525 try:
526 data = json.loads(result.output)
527 # If JSON, errors list must be non-empty or results non-empty.
528 assert isinstance(data, dict)
529 except json.JSONDecodeError:
530 pass # text-mode output is also acceptable here
531
532
533 # ---------------------------------------------------------------------------
534 # Stress
535 # ---------------------------------------------------------------------------
536
537
538 class TestCatStress:
539 @pytest.fixture
540 def large_repo(
541 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
542 ) -> pathlib.Path:
543 monkeypatch.chdir(tmp_path)
544 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
545 runner.invoke(cli, ["init", "--domain", "code"])
546
547 lines: list[str] = []
548 for i in range(200):
549 lines.append(f"def symbol_{i:04d}(x: int) -> int:")
550 lines.append(f" return x + {i}")
551 lines.append("")
552 (tmp_path / "big.py").write_text("\n".join(lines))
553
554 r = runner.invoke(cli, ["commit", "-m", "big module"])
555 assert r.exit_code == 0, r.output
556 return tmp_path
557
558 def test_all_200_symbols_under_5s(self, large_repo: pathlib.Path) -> None:
559 start = time.monotonic()
560 result = runner.invoke(cli, ["code", "cat", "--all", "big.py"])
561 elapsed = time.monotonic() - start
562 assert result.exit_code == 0, result.output
563 assert elapsed < 5.0, f"--all on 200 symbols took {elapsed:.2f}s"
564 assert "symbol_0000" in result.output
565 assert "symbol_0199" in result.output
566
567 def test_50_addresses_batch_under_5s(self, large_repo: pathlib.Path) -> None:
568 addresses = [f"big.py::symbol_{i:04d}" for i in range(50)]
569 start = time.monotonic()
570 result = runner.invoke(cli, ["code", "cat", "--json"] + addresses)
571 elapsed = time.monotonic() - start
572 assert result.exit_code == 0, result.output
573 assert elapsed < 5.0, f"50-address batch took {elapsed:.2f}s"
574 data = json.loads(result.output)
575 assert len(data["results"]) == 50
576
577 def test_all_json_200_symbols_schema_valid(self, large_repo: pathlib.Path) -> None:
578 result = runner.invoke(cli, ["code", "cat", "--all", "--json", "big.py"])
579 assert result.exit_code == 0, result.output
580 data = json.loads(result.output)
581 assert len(data["results"]) == 200
582 for r in data["results"]:
583 assert "source" in r
584 assert "lineno" in r
585
586 def test_file_cache_batch_same_file_under_2s(self, large_repo: pathlib.Path) -> None:
587 """50 addresses to the same file should benefit from caching: one read, one parse."""
588 addresses = [f"big.py::symbol_{i:04d}" for i in range(50)]
589 start = time.monotonic()
590 result = runner.invoke(cli, ["code", "cat", "--json"] + addresses)
591 elapsed = time.monotonic() - start
592 assert result.exit_code == 0, result.output
593 # With caching, 50 same-file lookups should be fast.
594 assert elapsed < 2.0, f"50 same-file addresses took {elapsed:.2f}s — cache may not be working"
595 data = json.loads(result.output)
596 assert len(data["results"]) == 50
597
598 def test_duration_ms_in_large_batch(self, large_repo: pathlib.Path) -> None:
599 addresses = [f"big.py::symbol_{i:04d}" for i in range(20)]
600 result = runner.invoke(cli, ["code", "cat", "--json"] + addresses)
601 assert result.exit_code == 0
602 data = json.loads(result.output)
603 assert isinstance(data["duration_ms"], float)
604 assert data["duration_ms"] >= 0.0
605
606
607 # ---------------------------------------------------------------------------
608 # Flag registration tests
609 # ---------------------------------------------------------------------------
610
611 import argparse as _argparse
612 from muse.cli.commands.cat import register as _register_cat
613
614
615 def _parse_cat(*args: str) -> _argparse.Namespace:
616 """Build an argument parser via register() and parse args."""
617 root_p = _argparse.ArgumentParser()
618 subs = root_p.add_subparsers(dest="cmd")
619 _register_cat(subs)
620 return root_p.parse_args(["cat", *args])
621
622
623 class TestRegisterFlags:
624 def test_default_json_out_is_false(self) -> None:
625 ns = _parse_cat("src/foo.py::MyFn")
626 assert ns.json_out is False
627
628 def test_json_flag_sets_json_out(self) -> None:
629 ns = _parse_cat("src/foo.py::MyFn", "--json")
630 assert ns.json_out is True
631
632 def test_j_shorthand_sets_json_out(self) -> None:
633 ns = _parse_cat("src/foo.py::MyFn", "-j")
634 assert ns.json_out is True
635
636 def test_line_numbers_has_no_n_shorthand(self) -> None:
637 import pytest
638 with pytest.raises(SystemExit):
639 _parse_cat("src/foo.py::MyFn", "-n")
640
641 def test_all_flag(self) -> None:
642 ns = _parse_cat("src/foo.py", "--all")
643 assert ns.all_symbols is True
File History 3 commits
sha256:b636f72dcba9e190afb980bece906fa5b717fbde014b76ef023df8cb96e01eb9 docs: expand cache plan with all seven testing tiers and do… Sonnet 4.6 131 days ago
sha256:7f9e2ef5286aedad9c1e6011b4c46ca27f39dbdad6e3409357e36b26e46b3b7c docs: docstring sprint for-each-ref→hotspots — idiomatic ru… Sonnet 4.6 patch 138 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 140 days ago