gabriel / muse public

test_app.py file-level

at sha256:8 · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 πŸ’₯ blast risk
sha256:4 Merge branch 'dev' into main · gabriel · Jun 17, 2026
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 json
31 import os
32 import pathlib
33 import sys
34 from unittest.mock import patch
35
36 import pytest
37
38 from muse.core.paths import head_path, muse_dir, repo_json_path
39 from tests.cli_test_helper import CliRunner, InvokeResult
40
41 runner = CliRunner()
42 cli = None # CliRunner ignores this argument
43
44
45 # ---------------------------------------------------------------------------
46 # Helpers
47 # ---------------------------------------------------------------------------
48
49
50 def _invoke(*args: str) -> InvokeResult:
51 return runner.invoke(cli, list(args))
52
53
54 # ---------------------------------------------------------------------------
55 # Unit β€” _MuseArgumentParser
56 # ---------------------------------------------------------------------------
57
58
59 class TestMuseArgumentParser:
60 def test_close_match_suggestion(self) -> None:
61 """`muse comit` should suggest 'commit'."""
62 result = _invoke("comit")
63 # argparse exits 2 for invalid choice
64 assert result.exit_code == 2
65 assert "Did you mean" in result.output or "Did you mean" in (result.stderr or "")
66
67 def test_no_suggestion_for_gibberish(self) -> None:
68 """`muse zzzzz` has no close match β€” no 'Did you mean' line."""
69 result = _invoke("zzzzz")
70 assert result.exit_code == 2
71 assert "Did you mean" not in result.output
72 assert "Did you mean" not in (result.stderr or "")
73
74 def test_multiple_close_matches(self) -> None:
75 """`muse brnach` is close to 'branch'; suggestion appears."""
76 result = _invoke("brnach")
77 assert result.exit_code == 2
78 combined = result.output + (result.stderr or "")
79 assert "Did you mean" in combined
80
81
82 # ---------------------------------------------------------------------------
83 # Unit β€” --version / -V
84 # ---------------------------------------------------------------------------
85
86
87 class TestVersion:
88 def test_long_flag(self) -> None:
89 result = _invoke("--version")
90 assert result.exit_code == 0
91 assert "muse" in result.output
92
93 def test_short_flag(self) -> None:
94 result = _invoke("-V")
95 assert result.exit_code == 0
96 assert "muse" in result.output
97
98 def test_version_matches_package(self) -> None:
99 from muse._version import __version__
100 result = _invoke("--version")
101 assert __version__ in result.output
102
103
104 # ---------------------------------------------------------------------------
105 # Unit β€” --help / no args
106 # ---------------------------------------------------------------------------
107
108
109 class TestHelp:
110 def test_help_flag_exits_zero(self) -> None:
111 result = _invoke("--help")
112 assert result.exit_code == 0
113
114 def test_help_mentions_version(self) -> None:
115 result = _invoke("--help")
116 assert "--version" in result.output or "-V" in result.output
117
118 def test_no_args_exits_zero(self) -> None:
119 result = _invoke()
120 assert result.exit_code == 0
121
122 def test_no_args_prints_usage(self) -> None:
123 result = _invoke()
124 assert "muse" in result.output.lower()
125
126
127 # ---------------------------------------------------------------------------
128 # Unit β€” unknown command
129 # ---------------------------------------------------------------------------
130
131
132 class TestUnknownCommand:
133 def test_unknown_exits_two(self) -> None:
134 result = _invoke("not-a-real-command-xyz")
135 assert result.exit_code == 2
136
137 def test_unknown_error_message(self) -> None:
138 result = _invoke("not-a-real-command-xyz")
139 combined = result.output + (result.stderr or "")
140 assert "invalid choice" in combined.lower() or "error" in combined.lower()
141
142
143 # ---------------------------------------------------------------------------
144 # Unit β€” -C flag
145 # ---------------------------------------------------------------------------
146
147
148 class TestChangeDirFlag:
149 def test_valid_dir_succeeds(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
150 """-C <existing dir> changes cwd and runs the command."""
151 # Create a minimal muse repo so `muse status` works
152 muse_dir(tmp_path).mkdir()
153 (repo_json_path(tmp_path)).write_text('{"id":"r","name":"r"}')
154 (head_path(tmp_path)).write_text("ref: refs/heads/main\n")
155
156 result = _invoke("-C", str(tmp_path), "status")
157 # We don't care about the exact output β€” just that -C didn't error.
158 # Exit code may be non-zero if no commits, but it must not be 1 (chdir error).
159 assert result.exit_code != 1
160
161 def test_nonexistent_dir_exits_one(self, tmp_path: pathlib.Path) -> None:
162 """-C <missing dir> prints error to stderr and exits 1."""
163 missing = str(tmp_path / "does_not_exist")
164 result = _invoke("-C", missing)
165 assert result.exit_code == 1
166 combined = result.output + (result.stderr or "")
167 assert "cannot change to directory" in combined
168
169 def test_ansi_in_path_is_sanitized(self, tmp_path: pathlib.Path) -> None:
170 """-C with ANSI escape in path must not emit raw ESC bytes in error output."""
171 ansi_path = str(tmp_path / "\x1b[31mmalicious\x1b[0m")
172 result = _invoke("-C", ansi_path)
173 assert result.exit_code == 1
174 # Raw ESC character must not appear in the error output
175 combined = result.output + (result.stderr or "")
176 assert "\x1b" not in combined
177
178
179 # ---------------------------------------------------------------------------
180 # Unit β€” namespace commands without subcommand
181 # ---------------------------------------------------------------------------
182
183
184 class TestNamespaceWithoutSubcommand:
185 def test_code_alone_exits_nonzero(self) -> None:
186 result = _invoke("code")
187 assert result.exit_code != 0
188
189 def test_midi_alone_exits_nonzero(self) -> None:
190 result = _invoke("midi")
191 assert result.exit_code != 0
192
193 def test_coord_alone_exits_nonzero(self) -> None:
194 result = _invoke("coord")
195 assert result.exit_code != 0
196
197
198 # ---------------------------------------------------------------------------
199 # Integration β€” dispatch reaches a real command
200 # ---------------------------------------------------------------------------
201
202
203 class TestDispatch:
204 def test_version_integration(self) -> None:
205 """End-to-end: CliRunner β†’ main() β†’ _version import β†’ print β†’ exit 0."""
206 from muse._version import __version__
207 result = _invoke("--version")
208 assert result.exit_code == 0
209 assert __version__ in result.output
210
211 def test_help_integration(self) -> None:
212 """End-to-end: CliRunner β†’ main() β†’ parser.print_help() β†’ exit 0."""
213 result = _invoke("--help")
214 assert result.exit_code == 0
215 # Help must mention at least one known top-level command
216 assert "commit" in result.output or "status" in result.output or "branch" in result.output
217
218 def test_chdir_integration(self, tmp_path: pathlib.Path) -> None:
219 """-C DIR changes cwd; a command that reads cwd sees the new path."""
220 # `muse init` reads/writes to cwd. We just verify that -C with a valid
221 # dir does not produce the "cannot change to directory" error (exit 1).
222 # Full cwd-mutation verification is handled by test_valid_dir_succeeds.
223 result = _invoke("-C", str(tmp_path), "--version")
224 # --version is processed BEFORE -C, so cwd is unchanged β€” but no error
225 assert result.exit_code == 0
226
227 # Verify -C is actually processed by running a command that errors
228 # meaningfully only when cwd is wrong: pass an existing dir, expect no
229 # exit-code 1 (which is the -C chdir-failure sentinel).
230 result2 = _invoke("-C", str(tmp_path), "status")
231 assert result2.exit_code != 1
232
233
234 # ──────────────────────────────────────────────────────────────────────────────
235 # CliRunner ergonomics
236 # ──────────────────────────────────────────────────────────────────────────────
237
238
239 class TestCliRunnerStdoutStderr:
240 """result.output is stdout-only; result.stderr is stderr-only."""
241
242 def test_stdout_only_in_output(self, tmp_path: pathlib.Path) -> None:
243 """result.output must not contain stderr text."""
244 runner.invoke(cli, ["init"], cwd=tmp_path)
245 result = runner.invoke(cli, ["branch", "-d", "nonexistent"], cwd=tmp_path)
246 assert result.exit_code != 0
247 assert "nonexistent" not in result.output # error went to stderr
248 assert "nonexistent" in result.stderr
249
250 def test_stderr_only_in_stderr(self, tmp_path: pathlib.Path) -> None:
251 """result.stderr must not bleed into result.output."""
252 runner.invoke(cli, ["init"], cwd=tmp_path)
253 result = runner.invoke(cli, ["branch", "-d", "nonexistent"], cwd=tmp_path)
254 assert result.exit_code != 0
255 assert result.output == ""
256
257 def test_json_stdout_unpolluted_by_stderr(self, tmp_path: pathlib.Path) -> None:
258 """JSON output on stdout is parseable without stripping stderr noise."""
259 runner.invoke(cli, ["init"], cwd=tmp_path)
260 (tmp_path / "a.py").write_text("x = 1\n")
261 runner.invoke(cli, ["commit", "-m", "init"], cwd=tmp_path)
262 result = runner.invoke(cli, ["branch", "--json"], cwd=tmp_path)
263 assert result.exit_code == 0
264 data = json.loads(result.output) # must not raise
265 assert isinstance(data, list)
266
267
268 class TestCliRunnerCwd:
269 """invoke(cwd=) changes directory for the duration of the call."""
270
271 def test_cwd_initialises_repo_in_target(self, tmp_path: pathlib.Path) -> None:
272 """init with cwd= creates .muse in tmp_path, not CWD."""
273 result = runner.invoke(cli, ["init"], cwd=tmp_path)
274 assert result.exit_code == 0
275 assert muse_dir(tmp_path).exists()
276
277 def test_cwd_restores_after_invoke(self, tmp_path: pathlib.Path) -> None:
278 """CWD is restored to its original value after invoke returns."""
279 import os
280 before = os.getcwd()
281 runner.invoke(cli, ["init"], cwd=tmp_path)
282 assert os.getcwd() == before
283
284 def test_cwd_isolates_concurrent_calls(self, tmp_path: pathlib.Path) -> None:
285 """Two repos can be initialised in separate tmp dirs without interference."""
286 dir_a = tmp_path / "a"
287 dir_b = tmp_path / "b"
288 dir_a.mkdir()
289 dir_b.mkdir()
290 runner.invoke(cli, ["init"], cwd=dir_a)
291 runner.invoke(cli, ["init"], cwd=dir_b)
292 assert muse_dir(dir_a).exists()
293 assert muse_dir(dir_b).exists()