cgcardona / muse public
test_cli_workflow.py python
256 lines 9.8 KB
e6786943 feat: upgrade to Python 3.14, drop from __future__ import annotations 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
51 class TestCommit:
52 def test_commit_with_message(self, repo: pathlib.Path) -> None:
53 _write(repo, "beat.mid")
54 result = runner.invoke(cli, ["commit", "-m", "Initial commit"])
55 assert result.exit_code == 0
56 assert "Initial commit" in result.output
57
58 def test_nothing_to_commit(self, repo: pathlib.Path) -> None:
59 _write(repo, "beat.mid")
60 runner.invoke(cli, ["commit", "-m", "First"])
61 result = runner.invoke(cli, ["commit", "-m", "Second"])
62 assert result.exit_code == 0
63 assert "Nothing to commit" in result.output
64
65 def test_allow_empty(self, repo: pathlib.Path) -> None:
66 result = runner.invoke(cli, ["commit", "-m", "Empty", "--allow-empty"])
67 assert result.exit_code == 0
68
69 def test_message_required(self, repo: pathlib.Path) -> None:
70 _write(repo, "beat.mid")
71 result = runner.invoke(cli, ["commit"])
72 assert result.exit_code != 0
73
74 def test_section_metadata(self, repo: pathlib.Path) -> None:
75 _write(repo, "beat.mid")
76 result = runner.invoke(cli, ["commit", "-m", "Chorus take", "--section", "chorus"])
77 assert result.exit_code == 0
78
79 from muse.core.store import get_head_commit_id, read_commit
80 import json
81 repo_id = json.loads((repo / ".muse" / "repo.json").read_text())["repo_id"]
82 commit_id = get_head_commit_id(repo, "main")
83 commit = read_commit(repo, commit_id)
84 assert commit is not None
85 assert commit.metadata.get("section") == "chorus"
86
87
88 class TestStatus:
89 def test_clean_after_commit(self, repo: pathlib.Path) -> None:
90 _write(repo, "beat.mid")
91 runner.invoke(cli, ["commit", "-m", "First"])
92 result = runner.invoke(cli, ["status"])
93 assert result.exit_code == 0
94 assert "Nothing to commit" in result.output
95
96 def test_shows_new_file(self, repo: pathlib.Path) -> None:
97 _write(repo, "beat.mid")
98 result = runner.invoke(cli, ["status"])
99 assert result.exit_code == 0
100 assert "beat.mid" in result.output
101
102 def test_short_flag(self, repo: pathlib.Path) -> None:
103 _write(repo, "beat.mid")
104 result = runner.invoke(cli, ["status", "--short"])
105 assert result.exit_code == 0
106 assert "A " in result.output
107
108 def test_porcelain_flag(self, repo: pathlib.Path) -> None:
109 _write(repo, "beat.mid")
110 result = runner.invoke(cli, ["status", "--porcelain"])
111 assert result.exit_code == 0
112 assert "## main" in result.output
113
114
115 class TestLog:
116 def test_empty_log(self, repo: pathlib.Path) -> None:
117 result = runner.invoke(cli, ["log"])
118 assert result.exit_code == 0
119 assert "no commits" in result.output
120
121 def test_shows_commit(self, repo: pathlib.Path) -> None:
122 _write(repo, "beat.mid")
123 runner.invoke(cli, ["commit", "-m", "First take"])
124 result = runner.invoke(cli, ["log"])
125 assert result.exit_code == 0
126 assert "First take" in result.output
127
128 def test_oneline(self, repo: pathlib.Path) -> None:
129 _write(repo, "beat.mid")
130 runner.invoke(cli, ["commit", "-m", "First take"])
131 result = runner.invoke(cli, ["log", "--oneline"])
132 assert result.exit_code == 0
133 assert "First take" in result.output
134 assert "Author:" not in result.output
135
136 def test_multiple_commits_newest_first(self, repo: pathlib.Path) -> None:
137 _write(repo, "a.mid")
138 runner.invoke(cli, ["commit", "-m", "First"])
139 _write(repo, "b.mid")
140 runner.invoke(cli, ["commit", "-m", "Second"])
141 result = runner.invoke(cli, ["log", "--oneline"])
142 lines = [l for l in result.output.strip().splitlines() if l.strip()]
143 assert "Second" in lines[0]
144 assert "First" in lines[1]
145
146
147 class TestBranch:
148 def test_list_shows_main(self, repo: pathlib.Path) -> None:
149 result = runner.invoke(cli, ["branch"])
150 assert result.exit_code == 0
151 assert "main" in result.output
152 assert "* " in result.output
153
154 def test_create_branch(self, repo: pathlib.Path) -> None:
155 result = runner.invoke(cli, ["branch", "feature/chorus"])
156 assert result.exit_code == 0
157 result = runner.invoke(cli, ["branch"])
158 assert "feature/chorus" in result.output
159
160 def test_delete_branch(self, repo: pathlib.Path) -> None:
161 runner.invoke(cli, ["branch", "feature/x"])
162 result = runner.invoke(cli, ["branch", "--delete", "feature/x"])
163 assert result.exit_code == 0
164 result = runner.invoke(cli, ["branch"])
165 assert "feature/x" not in result.output
166
167
168 class TestCheckout:
169 def test_create_and_switch(self, repo: pathlib.Path) -> None:
170 result = runner.invoke(cli, ["checkout", "-b", "feature/chorus"])
171 assert result.exit_code == 0
172 assert "feature/chorus" in result.output
173 status = runner.invoke(cli, ["status"])
174 assert "feature/chorus" in status.output
175
176 def test_switch_existing_branch(self, repo: pathlib.Path) -> None:
177 runner.invoke(cli, ["checkout", "-b", "feature/chorus"])
178 runner.invoke(cli, ["checkout", "main"])
179 result = runner.invoke(cli, ["status"])
180 assert "main" in result.output
181
182 def test_already_on_branch(self, repo: pathlib.Path) -> None:
183 result = runner.invoke(cli, ["checkout", "main"])
184 assert result.exit_code == 0
185 assert "Already on" in result.output
186
187
188 class TestMerge:
189 def test_fast_forward(self, repo: pathlib.Path) -> None:
190 _write(repo, "verse.mid")
191 runner.invoke(cli, ["commit", "-m", "Verse"])
192 runner.invoke(cli, ["checkout", "-b", "feature/chorus"])
193 _write(repo, "chorus.mid")
194 runner.invoke(cli, ["commit", "-m", "Add chorus"])
195 runner.invoke(cli, ["checkout", "main"])
196 result = runner.invoke(cli, ["merge", "feature/chorus"])
197 assert result.exit_code == 0
198 assert "Fast-forward" in result.output
199
200 def test_clean_three_way_merge(self, repo: pathlib.Path) -> None:
201 _write(repo, "base.mid")
202 runner.invoke(cli, ["commit", "-m", "Base"])
203 runner.invoke(cli, ["checkout", "-b", "branch-a"])
204 _write(repo, "a.mid")
205 runner.invoke(cli, ["commit", "-m", "Add A"])
206 runner.invoke(cli, ["checkout", "main"])
207 runner.invoke(cli, ["checkout", "-b", "branch-b"])
208 _write(repo, "b.mid")
209 runner.invoke(cli, ["commit", "-m", "Add B"])
210 runner.invoke(cli, ["checkout", "main"])
211 result = runner.invoke(cli, ["merge", "branch-a"])
212 assert result.exit_code == 0
213
214 def test_cannot_merge_self(self, repo: pathlib.Path) -> None:
215 result = runner.invoke(cli, ["merge", "main"])
216 assert result.exit_code != 0
217
218
219 class TestDiff:
220 def test_no_diff_clean(self, repo: pathlib.Path) -> None:
221 _write(repo, "beat.mid")
222 runner.invoke(cli, ["commit", "-m", "First"])
223 result = runner.invoke(cli, ["diff"])
224 assert result.exit_code == 0
225 assert "No differences" in result.output
226
227 def test_shows_new_file(self, repo: pathlib.Path) -> None:
228 _write(repo, "beat.mid")
229 runner.invoke(cli, ["commit", "-m", "First"])
230 _write(repo, "lead.mid")
231 result = runner.invoke(cli, ["diff"])
232 assert result.exit_code == 0
233 assert "lead.mid" in result.output
234
235
236 class TestTag:
237 def test_add_and_list(self, repo: pathlib.Path) -> None:
238 _write(repo, "beat.mid")
239 runner.invoke(cli, ["commit", "-m", "Tagged take"])
240 result = runner.invoke(cli, ["tag", "add", "emotion:joyful"])
241 assert result.exit_code == 0
242 result = runner.invoke(cli, ["tag", "list"])
243 assert "emotion:joyful" in result.output
244
245
246 class TestStash:
247 def test_stash_and_pop(self, repo: pathlib.Path) -> None:
248 _write(repo, "beat.mid")
249 runner.invoke(cli, ["commit", "-m", "First"])
250 _write(repo, "lead.mid")
251 result = runner.invoke(cli, ["stash"])
252 assert result.exit_code == 0
253 assert not (repo / "muse-work" / "lead.mid").exists()
254 result = runner.invoke(cli, ["stash", "pop"])
255 assert result.exit_code == 0
256 assert (repo / "muse-work" / "lead.mid").exists()