cgcardona / muse public
test_cli_workflow.py python
330 lines 12.9 KB
59a915a4 refactor: repo root is the working tree — remove state/ subdirectory Gabriel Cardona <gabriel@tellurstori.com> 6h ago
1 """End-to-end CLI workflow tests — init, commit, log, status, branch, merge."""
2
3 import pathlib
4
5 import pytest
6 from typer.testing import CliRunner
7
8 from muse.cli.app import cli
9
10 runner = CliRunner()
11
12
13 @pytest.fixture
14 def repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
15 """Initialise a fresh Muse repo in tmp_path and set it as cwd."""
16 monkeypatch.chdir(tmp_path)
17 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
18 result = runner.invoke(cli, ["init"])
19 assert result.exit_code == 0, result.output
20 return tmp_path
21
22
23 def _write(repo: pathlib.Path, filename: str, content: str = "data") -> None:
24 (repo / filename).write_text(content)
25
26
27 class TestInit:
28 def test_creates_muse_dir(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
29 monkeypatch.chdir(tmp_path)
30 result = runner.invoke(cli, ["init"])
31 assert result.exit_code == 0
32 assert (tmp_path / ".muse").is_dir()
33 assert (tmp_path / ".muse" / "HEAD").exists()
34 assert (tmp_path / ".muse" / "repo.json").exists()
35 assert (tmp_path).is_dir()
36
37 def test_reinit_requires_force(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
38 monkeypatch.chdir(tmp_path)
39 runner.invoke(cli, ["init"])
40 result = runner.invoke(cli, ["init"])
41 assert result.exit_code != 0
42 assert "force" in result.output.lower()
43
44 def test_bare_repo(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
45 monkeypatch.chdir(tmp_path)
46 result = runner.invoke(cli, ["init", "--bare"])
47 assert result.exit_code == 0
48 # Bare repos have the internal store but no template files are copied.
49 assert (tmp_path / ".muse").exists()
50
51 def test_creates_museignore(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
52 monkeypatch.chdir(tmp_path)
53 result = runner.invoke(cli, ["init"])
54 assert result.exit_code == 0
55 ignore_file = tmp_path / ".museignore"
56 assert ignore_file.exists(), ".museignore should be created by muse init"
57
58 def test_museignore_is_valid_toml(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
59 import tomllib
60
61 monkeypatch.chdir(tmp_path)
62 runner.invoke(cli, ["init"])
63 ignore_file = tmp_path / ".museignore"
64 with ignore_file.open("rb") as fh:
65 config = tomllib.load(fh)
66 assert isinstance(config, dict), ".museignore must be valid TOML"
67
68 def test_museignore_has_global_section(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None:
69 import tomllib
70
71 monkeypatch.chdir(tmp_path)
72 runner.invoke(cli, ["init"])
73 with (tmp_path / ".museignore").open("rb") as fh:
74 config = tomllib.load(fh)
75 assert "global" in config, ".museignore should have a [global] section"
76 assert isinstance(config["global"].get("patterns"), list)
77
78 def test_museignore_has_domain_section_for_midi(
79 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
80 ) -> None:
81 import tomllib
82
83 monkeypatch.chdir(tmp_path)
84 runner.invoke(cli, ["init", "--domain", "midi"])
85 with (tmp_path / ".museignore").open("rb") as fh:
86 config = tomllib.load(fh)
87 domain_map = config.get("domain", {})
88 assert "midi" in domain_map, "[domain.midi] section should be present for --domain midi"
89
90 def test_museignore_has_domain_section_for_code(
91 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
92 ) -> None:
93 import tomllib
94
95 monkeypatch.chdir(tmp_path)
96 runner.invoke(cli, ["init", "--domain", "code"])
97 with (tmp_path / ".museignore").open("rb") as fh:
98 config = tomllib.load(fh)
99 domain_map = config.get("domain", {})
100 assert "code" in domain_map, "[domain.code] section should be present for --domain code"
101
102 def test_museignore_not_overwritten_on_reinit(
103 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
104 ) -> None:
105 monkeypatch.chdir(tmp_path)
106 runner.invoke(cli, ["init"])
107 custom = '[global]\npatterns = ["custom.txt"]\n'
108 (tmp_path / ".museignore").write_text(custom)
109 runner.invoke(cli, ["init", "--force"])
110 assert (tmp_path / ".museignore").read_text() == custom
111
112 def test_museignore_parseable_by_load_ignore_config(
113 self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch
114 ) -> None:
115 from muse.core.ignore import load_ignore_config, resolve_patterns
116
117 monkeypatch.chdir(tmp_path)
118 runner.invoke(cli, ["init", "--domain", "midi"])
119 config = load_ignore_config(tmp_path)
120 patterns = resolve_patterns(config, "midi")
121 assert isinstance(patterns, list)
122 assert len(patterns) > 0, "midi init should produce non-empty pattern list"
123
124
125 class TestCommit:
126 def test_commit_with_message(self, repo: pathlib.Path) -> None:
127 _write(repo, "beat.mid")
128 result = runner.invoke(cli, ["commit", "-m", "Initial commit"])
129 assert result.exit_code == 0
130 assert "Initial commit" in result.output
131
132 def test_nothing_to_commit(self, repo: pathlib.Path) -> None:
133 _write(repo, "beat.mid")
134 runner.invoke(cli, ["commit", "-m", "First"])
135 result = runner.invoke(cli, ["commit", "-m", "Second"])
136 assert result.exit_code == 0
137 assert "Nothing to commit" in result.output
138
139 def test_allow_empty(self, repo: pathlib.Path) -> None:
140 result = runner.invoke(cli, ["commit", "-m", "Empty", "--allow-empty"])
141 assert result.exit_code == 0
142
143 def test_message_required(self, repo: pathlib.Path) -> None:
144 _write(repo, "beat.mid")
145 result = runner.invoke(cli, ["commit"])
146 assert result.exit_code != 0
147
148 def test_section_metadata(self, repo: pathlib.Path) -> None:
149 _write(repo, "beat.mid")
150 result = runner.invoke(cli, ["commit", "-m", "Chorus take", "--section", "chorus"])
151 assert result.exit_code == 0
152
153 from muse.core.store import get_head_commit_id, read_commit
154 import json
155 repo_id = json.loads((repo / ".muse" / "repo.json").read_text())["repo_id"]
156 commit_id = get_head_commit_id(repo, "main")
157 commit = read_commit(repo, commit_id)
158 assert commit is not None
159 assert commit.metadata.get("section") == "chorus"
160
161
162 class TestStatus:
163 def test_clean_after_commit(self, repo: pathlib.Path) -> None:
164 _write(repo, "beat.mid")
165 runner.invoke(cli, ["commit", "-m", "First"])
166 result = runner.invoke(cli, ["status"])
167 assert result.exit_code == 0
168 assert "Nothing to commit" in result.output
169
170 def test_shows_new_file(self, repo: pathlib.Path) -> None:
171 _write(repo, "beat.mid")
172 result = runner.invoke(cli, ["status"])
173 assert result.exit_code == 0
174 assert "beat.mid" in result.output
175
176 def test_short_flag(self, repo: pathlib.Path) -> None:
177 _write(repo, "beat.mid")
178 result = runner.invoke(cli, ["status", "--short"])
179 assert result.exit_code == 0
180 assert "A " in result.output
181
182 def test_porcelain_flag(self, repo: pathlib.Path) -> None:
183 _write(repo, "beat.mid")
184 result = runner.invoke(cli, ["status", "--porcelain"])
185 assert result.exit_code == 0
186 assert "## main" in result.output
187
188
189 class TestLog:
190 def test_empty_log(self, repo: pathlib.Path) -> None:
191 result = runner.invoke(cli, ["log"])
192 assert result.exit_code == 0
193 assert "no commits" in result.output
194
195 def test_shows_commit(self, repo: pathlib.Path) -> None:
196 _write(repo, "beat.mid")
197 runner.invoke(cli, ["commit", "-m", "First take"])
198 result = runner.invoke(cli, ["log"])
199 assert result.exit_code == 0
200 assert "First take" in result.output
201
202 def test_oneline(self, repo: pathlib.Path) -> None:
203 _write(repo, "beat.mid")
204 runner.invoke(cli, ["commit", "-m", "First take"])
205 result = runner.invoke(cli, ["log", "--oneline"])
206 assert result.exit_code == 0
207 assert "First take" in result.output
208 assert "Author:" not in result.output
209
210 def test_multiple_commits_newest_first(self, repo: pathlib.Path) -> None:
211 _write(repo, "a.mid")
212 runner.invoke(cli, ["commit", "-m", "First"])
213 _write(repo, "b.mid")
214 runner.invoke(cli, ["commit", "-m", "Second"])
215 result = runner.invoke(cli, ["log", "--oneline"])
216 lines = [l for l in result.output.strip().splitlines() if l.strip()]
217 assert "Second" in lines[0]
218 assert "First" in lines[1]
219
220
221 class TestBranch:
222 def test_list_shows_main(self, repo: pathlib.Path) -> None:
223 result = runner.invoke(cli, ["branch"])
224 assert result.exit_code == 0
225 assert "main" in result.output
226 assert "* " in result.output
227
228 def test_create_branch(self, repo: pathlib.Path) -> None:
229 result = runner.invoke(cli, ["branch", "feature/chorus"])
230 assert result.exit_code == 0
231 result = runner.invoke(cli, ["branch"])
232 assert "feature/chorus" in result.output
233
234 def test_delete_branch(self, repo: pathlib.Path) -> None:
235 runner.invoke(cli, ["branch", "feature/x"])
236 result = runner.invoke(cli, ["branch", "--delete", "feature/x"])
237 assert result.exit_code == 0
238 result = runner.invoke(cli, ["branch"])
239 assert "feature/x" not in result.output
240
241
242 class TestCheckout:
243 def test_create_and_switch(self, repo: pathlib.Path) -> None:
244 result = runner.invoke(cli, ["checkout", "-b", "feature/chorus"])
245 assert result.exit_code == 0
246 assert "feature/chorus" in result.output
247 status = runner.invoke(cli, ["status"])
248 assert "feature/chorus" in status.output
249
250 def test_switch_existing_branch(self, repo: pathlib.Path) -> None:
251 runner.invoke(cli, ["checkout", "-b", "feature/chorus"])
252 runner.invoke(cli, ["checkout", "main"])
253 result = runner.invoke(cli, ["status"])
254 assert "main" in result.output
255
256 def test_already_on_branch(self, repo: pathlib.Path) -> None:
257 result = runner.invoke(cli, ["checkout", "main"])
258 assert result.exit_code == 0
259 assert "Already on" in result.output
260
261
262 class TestMerge:
263 def test_fast_forward(self, repo: pathlib.Path) -> None:
264 _write(repo, "verse.mid")
265 runner.invoke(cli, ["commit", "-m", "Verse"])
266 runner.invoke(cli, ["checkout", "-b", "feature/chorus"])
267 _write(repo, "chorus.mid")
268 runner.invoke(cli, ["commit", "-m", "Add chorus"])
269 runner.invoke(cli, ["checkout", "main"])
270 result = runner.invoke(cli, ["merge", "feature/chorus"])
271 assert result.exit_code == 0
272 assert "Fast-forward" in result.output
273
274 def test_clean_three_way_merge(self, repo: pathlib.Path) -> None:
275 _write(repo, "base.mid")
276 runner.invoke(cli, ["commit", "-m", "Base"])
277 runner.invoke(cli, ["checkout", "-b", "branch-a"])
278 _write(repo, "a.mid")
279 runner.invoke(cli, ["commit", "-m", "Add A"])
280 runner.invoke(cli, ["checkout", "main"])
281 runner.invoke(cli, ["checkout", "-b", "branch-b"])
282 _write(repo, "b.mid")
283 runner.invoke(cli, ["commit", "-m", "Add B"])
284 runner.invoke(cli, ["checkout", "main"])
285 result = runner.invoke(cli, ["merge", "branch-a"])
286 assert result.exit_code == 0
287
288 def test_cannot_merge_self(self, repo: pathlib.Path) -> None:
289 result = runner.invoke(cli, ["merge", "main"])
290 assert result.exit_code != 0
291
292
293 class TestDiff:
294 def test_no_diff_clean(self, repo: pathlib.Path) -> None:
295 _write(repo, "beat.mid")
296 runner.invoke(cli, ["commit", "-m", "First"])
297 result = runner.invoke(cli, ["diff"])
298 assert result.exit_code == 0
299 assert "No differences" in result.output
300
301 def test_shows_new_file(self, repo: pathlib.Path) -> None:
302 _write(repo, "beat.mid")
303 runner.invoke(cli, ["commit", "-m", "First"])
304 _write(repo, "lead.mid")
305 result = runner.invoke(cli, ["diff"])
306 assert result.exit_code == 0
307 assert "lead.mid" in result.output
308
309
310 class TestTag:
311 def test_add_and_list(self, repo: pathlib.Path) -> None:
312 _write(repo, "beat.mid")
313 runner.invoke(cli, ["commit", "-m", "Tagged take"])
314 result = runner.invoke(cli, ["tag", "add", "emotion:joyful"])
315 assert result.exit_code == 0
316 result = runner.invoke(cli, ["tag", "list"])
317 assert "emotion:joyful" in result.output
318
319
320 class TestStash:
321 def test_stash_and_pop(self, repo: pathlib.Path) -> None:
322 _write(repo, "beat.mid")
323 runner.invoke(cli, ["commit", "-m", "First"])
324 _write(repo, "lead.mid")
325 result = runner.invoke(cli, ["stash"])
326 assert result.exit_code == 0
327 assert not (repo / "lead.mid").exists()
328 result = runner.invoke(cli, ["stash", "pop"])
329 assert result.exit_code == 0
330 assert (repo / "lead.mid").exists()