gabriel / muse public
test_app.py python
229 lines 8.6 KB
Raw
sha256:1c4b3e3a9a1f300774c3ee662b572a698d5fd405bf765a71e6011a2e9c3eaaaa feat: Muse — version control for the agent era Human 74 days ago
1 """Tests for the top-level ``muse`` CLI entry point (muse/cli/app.py).
2
3 Coverage
4 --------
5 Unit
6 - _MuseArgumentParser.error() emits 'Did you mean?' for close-match typos.
7 - _MuseArgumentParser.error() does NOT suggest when no close match exists.
8 - main() --version / -V prints version string and exits 0.
9 - main() --help exits 0 and contains key sections.
10 - main() with no arguments exits 0 and prints help.
11 - main() with unknown command exits 2.
12 - main() unknown command with close match includes suggestion.
13 - main() -C with a valid existing directory succeeds.
14 - main() -C with a nonexistent directory exits 1 and prints to stderr.
15 - main() -C with ANSI escape sequences in path is sanitized (no raw ESC in output).
16 - main() namespace command without subcommand exits 2.
17 - main() dispatches to func on valid subcommand.
18
19 Integration
20 - --version output matches the installed __version__ string.
21 - -C changes the working directory before dispatch.
22
23 End-to-end (via CliRunner)
24 - muse --version round-trip through CliRunner.
25 - muse status dispatches successfully in a real repo.
26 """
27
28 from __future__ import annotations
29
30 import os
31 import pathlib
32 import sys
33 from unittest.mock import patch
34
35 import pytest
36
37 from tests.cli_test_helper import CliRunner, InvokeResult
38
39 runner = CliRunner()
40 cli = None # CliRunner ignores this argument
41
42
43 # ---------------------------------------------------------------------------
44 # Helpers
45 # ---------------------------------------------------------------------------
46
47
48 def _invoke(*args: str) -> InvokeResult:
49 return runner.invoke(cli, list(args))
50
51
52 # ---------------------------------------------------------------------------
53 # Unit — _MuseArgumentParser
54 # ---------------------------------------------------------------------------
55
56
57 class TestMuseArgumentParser:
58 def test_close_match_suggestion(self) -> None:
59 """`muse comit` should suggest 'commit'."""
60 result = _invoke("comit")
61 # argparse exits 2 for invalid choice
62 assert result.exit_code == 2
63 assert "Did you mean" in result.output or "Did you mean" in (result.stderr or "")
64
65 def test_no_suggestion_for_gibberish(self) -> None:
66 """`muse zzzzz` has no close match — no 'Did you mean' line."""
67 result = _invoke("zzzzz")
68 assert result.exit_code == 2
69 assert "Did you mean" not in result.output
70 assert "Did you mean" not in (result.stderr or "")
71
72 def test_multiple_close_matches(self) -> None:
73 """`muse brnach` is close to 'branch'; suggestion appears."""
74 result = _invoke("brnach")
75 assert result.exit_code == 2
76 combined = result.output + (result.stderr or "")
77 assert "Did you mean" in combined
78
79
80 # ---------------------------------------------------------------------------
81 # Unit — --version / -V
82 # ---------------------------------------------------------------------------
83
84
85 class TestVersion:
86 def test_long_flag(self) -> None:
87 result = _invoke("--version")
88 assert result.exit_code == 0
89 assert "muse" in result.output
90
91 def test_short_flag(self) -> None:
92 result = _invoke("-V")
93 assert result.exit_code == 0
94 assert "muse" in result.output
95
96 def test_version_matches_package(self) -> None:
97 from muse._version import __version__
98 result = _invoke("--version")
99 assert __version__ in result.output
100
101
102 # ---------------------------------------------------------------------------
103 # Unit — --help / no args
104 # ---------------------------------------------------------------------------
105
106
107 class TestHelp:
108 def test_help_flag_exits_zero(self) -> None:
109 result = _invoke("--help")
110 assert result.exit_code == 0
111
112 def test_help_mentions_version(self) -> None:
113 result = _invoke("--help")
114 assert "--version" in result.output or "-V" in result.output
115
116 def test_no_args_exits_zero(self) -> None:
117 result = _invoke()
118 assert result.exit_code == 0
119
120 def test_no_args_prints_usage(self) -> None:
121 result = _invoke()
122 assert "muse" in result.output.lower()
123
124
125 # ---------------------------------------------------------------------------
126 # Unit — unknown command
127 # ---------------------------------------------------------------------------
128
129
130 class TestUnknownCommand:
131 def test_unknown_exits_two(self) -> None:
132 result = _invoke("not-a-real-command-xyz")
133 assert result.exit_code == 2
134
135 def test_unknown_error_message(self) -> None:
136 result = _invoke("not-a-real-command-xyz")
137 combined = result.output + (result.stderr or "")
138 assert "invalid choice" in combined.lower() or "error" in combined.lower()
139
140
141 # ---------------------------------------------------------------------------
142 # Unit — -C flag
143 # ---------------------------------------------------------------------------
144
145
146 class TestChangeDirFlag:
147 def test_valid_dir_succeeds(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
148 """-C <existing dir> changes cwd and runs the command."""
149 # Create a minimal muse repo so `muse status` works
150 (tmp_path / ".muse").mkdir()
151 (tmp_path / ".muse" / "repo.json").write_text('{"id":"r","name":"r"}')
152 (tmp_path / ".muse" / "HEAD").write_text("ref: refs/heads/main\n")
153
154 result = _invoke("-C", str(tmp_path), "status")
155 # We don't care about the exact output — just that -C didn't error.
156 # Exit code may be non-zero if no commits, but it must not be 1 (chdir error).
157 assert result.exit_code != 1
158
159 def test_nonexistent_dir_exits_one(self, tmp_path: pathlib.Path) -> None:
160 """-C <missing dir> prints error to stderr and exits 1."""
161 missing = str(tmp_path / "does_not_exist")
162 result = _invoke("-C", missing)
163 assert result.exit_code == 1
164 combined = result.output + (result.stderr or "")
165 assert "cannot change to directory" in combined
166
167 def test_ansi_in_path_is_sanitized(self, tmp_path: pathlib.Path) -> None:
168 """-C with ANSI escape in path must not emit raw ESC bytes in error output."""
169 ansi_path = str(tmp_path / "\x1b[31mevil\x1b[0m")
170 result = _invoke("-C", ansi_path)
171 assert result.exit_code == 1
172 # Raw ESC character must not appear in the error output
173 combined = result.output + (result.stderr or "")
174 assert "\x1b" not in combined
175
176
177 # ---------------------------------------------------------------------------
178 # Unit — namespace commands without subcommand
179 # ---------------------------------------------------------------------------
180
181
182 class TestNamespaceWithoutSubcommand:
183 def test_code_alone_exits_nonzero(self) -> None:
184 result = _invoke("code")
185 assert result.exit_code != 0
186
187 def test_midi_alone_exits_nonzero(self) -> None:
188 result = _invoke("midi")
189 assert result.exit_code != 0
190
191 def test_coord_alone_exits_nonzero(self) -> None:
192 result = _invoke("coord")
193 assert result.exit_code != 0
194
195
196 # ---------------------------------------------------------------------------
197 # Integration — dispatch reaches a real command
198 # ---------------------------------------------------------------------------
199
200
201 class TestDispatch:
202 def test_version_integration(self) -> None:
203 """End-to-end: CliRunner → main() → _version import → print → exit 0."""
204 from muse._version import __version__
205 result = _invoke("--version")
206 assert result.exit_code == 0
207 assert __version__ in result.output
208
209 def test_help_integration(self) -> None:
210 """End-to-end: CliRunner → main() → parser.print_help() → exit 0."""
211 result = _invoke("--help")
212 assert result.exit_code == 0
213 # Help must mention at least one known top-level command
214 assert "commit" in result.output or "status" in result.output or "branch" in result.output
215
216 def test_chdir_integration(self, tmp_path: pathlib.Path) -> None:
217 """-C DIR changes cwd; a command that reads cwd sees the new path."""
218 # `muse init` reads/writes to cwd. We just verify that -C with a valid
219 # dir does not produce the "cannot change to directory" error (exit 1).
220 # Full cwd-mutation verification is handled by test_valid_dir_succeeds.
221 result = _invoke("-C", str(tmp_path), "--version")
222 # --version is processed BEFORE -C, so cwd is unchanged — but no error
223 assert result.exit_code == 0
224
225 # Verify -C is actually processed by running a command that errors
226 # meaningfully only when cwd is wrong: pass an existing dir, expect no
227 # exit-code 1 (which is the -C chdir-failure sentinel).
228 result2 = _invoke("-C", str(tmp_path), "status")
229 assert result2.exit_code != 1
File History 1 commit
sha256:1c4b3e3a9a1f300774c3ee662b572a698d5fd405bf765a71e6011a2e9c3eaaaa feat: Muse — version control for the agent era Human 74 days ago