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