cgcardona / muse public
test_cli_plugin_dispatch.py python
451 lines 16.8 KB
a82406f1 Wire MuseDomainPlugin into all CLI commands via plugin registry Gabriel Cardona <gabriel@tellurstori.com> 3d ago
1 """Integration tests verifying CLI commands dispatch through the domain plugin.
2
3 Each test confirms that the relevant plugin method is called when the CLI
4 command runs, and that the command's output matches the plugin's semantics.
5 These tests use unittest.mock.patch to intercept plugin calls and also
6 perform end-to-end output assertions.
7 """
8 from __future__ import annotations
9
10 import pathlib
11 from unittest.mock import MagicMock, patch
12
13 import pytest
14 from typer.testing import CliRunner
15
16 from muse.cli.app import cli
17 from muse.domain import (
18 DeltaManifest,
19 DriftReport,
20 LiveState,
21 MergeResult,
22 MuseDomainPlugin,
23 SnapshotManifest,
24 StateSnapshot,
25 )
26 from muse.plugins.music.plugin import MusicPlugin
27
28 runner = CliRunner()
29
30
31 @pytest.fixture
32 def repo(tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> pathlib.Path:
33 """Initialise a fresh Muse repo in tmp_path and set it as cwd."""
34 monkeypatch.chdir(tmp_path)
35 monkeypatch.setenv("MUSE_REPO_ROOT", str(tmp_path))
36 result = runner.invoke(cli, ["init"])
37 assert result.exit_code == 0, result.output
38 return tmp_path
39
40
41 def _write(repo: pathlib.Path, filename: str, content: str = "data") -> None:
42 (repo / "muse-work" / filename).write_text(content)
43
44
45 def _commit(msg: str = "initial") -> None:
46 result = runner.invoke(cli, ["commit", "-m", msg])
47 assert result.exit_code == 0, result.output
48
49
50 # ---------------------------------------------------------------------------
51 # commit
52 # ---------------------------------------------------------------------------
53
54
55 class TestCommitDispatch:
56 def test_commit_calls_plugin_snapshot(self, repo: pathlib.Path) -> None:
57 _write(repo, "beat.mid", "drums")
58 with patch("muse.cli.commands.commit.resolve_plugin") as mock_resolve:
59 real_plugin = MusicPlugin()
60 mock_plugin = MagicMock(spec=MuseDomainPlugin)
61 mock_plugin.snapshot.side_effect = real_plugin.snapshot
62 mock_resolve.return_value = mock_plugin
63
64 result = runner.invoke(cli, ["commit", "-m", "test"])
65 assert result.exit_code == 0, result.output
66 mock_plugin.snapshot.assert_called_once()
67
68 def test_commit_snapshot_argument_is_workdir_path(self, repo: pathlib.Path) -> None:
69 _write(repo, "beat.mid", "drums")
70 captured_args: list[LiveState] = []
71
72 with patch("muse.cli.commands.commit.resolve_plugin") as mock_resolve:
73 real_plugin = MusicPlugin()
74 mock_plugin = MagicMock(spec=MuseDomainPlugin)
75
76 def capture_snapshot(live_state: LiveState) -> SnapshotManifest:
77 captured_args.append(live_state)
78 return real_plugin.snapshot(live_state)
79
80 mock_plugin.snapshot.side_effect = capture_snapshot
81 mock_resolve.return_value = mock_plugin
82
83 runner.invoke(cli, ["commit", "-m", "test"])
84 assert len(captured_args) == 1
85 assert isinstance(captured_args[0], pathlib.Path)
86 assert captured_args[0].name == "muse-work"
87
88 def test_commit_uses_snapshot_files_for_manifest(self, repo: pathlib.Path) -> None:
89 _write(repo, "track.mid", "content")
90 result = runner.invoke(cli, ["commit", "-m", "via plugin"])
91 assert result.exit_code == 0
92 assert "via plugin" in result.output
93
94
95 # ---------------------------------------------------------------------------
96 # status
97 # ---------------------------------------------------------------------------
98
99
100 class TestStatusDispatch:
101 def test_status_calls_plugin_drift(self, repo: pathlib.Path) -> None:
102 _write(repo, "beat.mid")
103 _commit()
104 _write(repo, "new.mid", "extra")
105
106 with patch("muse.cli.commands.status.resolve_plugin") as mock_resolve:
107 real_plugin = MusicPlugin()
108 mock_plugin = MagicMock(spec=MuseDomainPlugin)
109 mock_plugin.drift.side_effect = real_plugin.drift
110 mock_resolve.return_value = mock_plugin
111
112 result = runner.invoke(cli, ["status"])
113 assert result.exit_code == 0
114 mock_plugin.drift.assert_called_once()
115
116 def test_status_clean_tree_via_drift(self, repo: pathlib.Path) -> None:
117 _write(repo, "beat.mid")
118 _commit()
119 result = runner.invoke(cli, ["status"])
120 assert result.exit_code == 0
121 assert "clean" in result.output
122
123 def test_status_shows_new_file(self, repo: pathlib.Path) -> None:
124 _write(repo, "beat.mid")
125 _commit()
126 _write(repo, "new.mid", "extra")
127 result = runner.invoke(cli, ["status"])
128 assert result.exit_code == 0
129 assert "new.mid" in result.output
130
131 def test_status_shows_deleted_file(self, repo: pathlib.Path) -> None:
132 _write(repo, "beat.mid")
133 _commit()
134 (repo / "muse-work" / "beat.mid").unlink()
135 result = runner.invoke(cli, ["status"])
136 assert result.exit_code == 0
137 assert "beat.mid" in result.output
138
139 def test_status_drift_report_drives_output(self, repo: pathlib.Path) -> None:
140 """Patch drift() to return a controlled DriftReport and verify CLI echoes it."""
141 _write(repo, "beat.mid")
142 _commit()
143
144 fake_delta = DeltaManifest(domain="music", added=["injected.mid"], removed=[], modified=[])
145 fake_report = DriftReport(has_drift=True, summary="1 added", delta=fake_delta)
146
147 with patch("muse.cli.commands.status.resolve_plugin") as mock_resolve:
148 mock_plugin = MagicMock(spec=MuseDomainPlugin)
149 mock_plugin.drift.return_value = fake_report
150 mock_resolve.return_value = mock_plugin
151
152 result = runner.invoke(cli, ["status"])
153 assert "injected.mid" in result.output
154
155
156 # ---------------------------------------------------------------------------
157 # diff
158 # ---------------------------------------------------------------------------
159
160
161 class TestDiffDispatch:
162 def test_diff_calls_plugin_diff(self, repo: pathlib.Path) -> None:
163 _write(repo, "beat.mid")
164 _commit()
165 _write(repo, "lead.mid", "solo")
166
167 with patch("muse.cli.commands.diff.resolve_plugin") as mock_resolve:
168 real_plugin = MusicPlugin()
169 mock_plugin = MagicMock(spec=MuseDomainPlugin)
170 mock_plugin.snapshot.side_effect = real_plugin.snapshot
171 mock_plugin.diff.side_effect = real_plugin.diff
172 mock_resolve.return_value = mock_plugin
173
174 result = runner.invoke(cli, ["diff"])
175 assert result.exit_code == 0
176 mock_plugin.snapshot.assert_called_once()
177 mock_plugin.diff.assert_called_once()
178
179 def test_diff_calls_plugin_snapshot_for_workdir(self, repo: pathlib.Path) -> None:
180 _write(repo, "beat.mid")
181 _commit()
182 _write(repo, "extra.mid", "new")
183
184 captured: list[LiveState] = []
185 with patch("muse.cli.commands.diff.resolve_plugin") as mock_resolve:
186 real_plugin = MusicPlugin()
187 mock_plugin = MagicMock(spec=MuseDomainPlugin)
188
189 def cap_snapshot(ls: LiveState) -> SnapshotManifest:
190 captured.append(ls)
191 return real_plugin.snapshot(ls)
192
193 mock_plugin.snapshot.side_effect = cap_snapshot
194 mock_plugin.diff.side_effect = real_plugin.diff
195 mock_resolve.return_value = mock_plugin
196
197 runner.invoke(cli, ["diff"])
198 assert any(isinstance(a, pathlib.Path) for a in captured)
199
200 def test_diff_shows_added_file(self, repo: pathlib.Path) -> None:
201 _write(repo, "beat.mid")
202 _commit()
203 _write(repo, "new.mid", "extra")
204 result = runner.invoke(cli, ["diff"])
205 assert result.exit_code == 0
206 assert "new.mid" in result.output
207
208 def test_diff_no_differences(self, repo: pathlib.Path) -> None:
209 _write(repo, "beat.mid")
210 _commit()
211 result = runner.invoke(cli, ["diff"])
212 assert result.exit_code == 0
213 assert "No differences" in result.output
214
215 def test_diff_delta_drives_output(self, repo: pathlib.Path) -> None:
216 """Patch plugin.diff() to return a controlled delta and verify CLI output."""
217 _write(repo, "beat.mid")
218 _commit()
219
220 fake_delta = DeltaManifest(
221 domain="music",
222 added=["injected.mid"],
223 removed=["gone.mid"],
224 modified=[],
225 )
226 with patch("muse.cli.commands.diff.resolve_plugin") as mock_resolve:
227 real_plugin = MusicPlugin()
228 mock_plugin = MagicMock(spec=MuseDomainPlugin)
229 mock_plugin.snapshot.side_effect = real_plugin.snapshot
230 mock_plugin.diff.return_value = fake_delta
231 mock_resolve.return_value = mock_plugin
232
233 result = runner.invoke(cli, ["diff"])
234 assert "injected.mid" in result.output
235 assert "gone.mid" in result.output
236
237
238 # ---------------------------------------------------------------------------
239 # merge
240 # ---------------------------------------------------------------------------
241
242
243 class TestMergeDispatch:
244 def test_merge_calls_plugin_merge(self, repo: pathlib.Path) -> None:
245 _write(repo, "beat.mid", "v1")
246 _commit("base")
247
248 runner.invoke(cli, ["branch", "feature"])
249 runner.invoke(cli, ["checkout", "feature"])
250 _write(repo, "lead.mid", "solo")
251 _commit("add lead")
252
253 runner.invoke(cli, ["checkout", "main"])
254 # Add a commit on main so both branches have diverged — forces a real merge.
255 _write(repo, "bass.mid", "bass line")
256 _commit("add bass on main")
257
258 with patch("muse.cli.commands.merge.resolve_plugin") as mock_resolve:
259 real_plugin = MusicPlugin()
260 mock_plugin = MagicMock(spec=MuseDomainPlugin)
261 mock_plugin.merge.side_effect = real_plugin.merge
262 mock_resolve.return_value = mock_plugin
263
264 result = runner.invoke(cli, ["merge", "feature"])
265 assert result.exit_code == 0
266 mock_plugin.merge.assert_called_once()
267
268 def test_merge_plugin_merge_result_drives_outcome(self, repo: pathlib.Path) -> None:
269 _write(repo, "beat.mid", "v1")
270 _commit("base")
271
272 runner.invoke(cli, ["branch", "feature"])
273 runner.invoke(cli, ["checkout", "feature"])
274 _write(repo, "lead.mid", "solo")
275 _commit("add lead")
276
277 runner.invoke(cli, ["checkout", "main"])
278 _write(repo, "bass.mid", "bass line")
279 _commit("add bass on main")
280
281 fake_result = MergeResult(
282 merged=SnapshotManifest(files={"injected.mid": "abc"}, domain="music"),
283 conflicts=[],
284 )
285 with patch("muse.cli.commands.merge.resolve_plugin") as mock_resolve:
286 mock_plugin = MagicMock(spec=MuseDomainPlugin)
287 mock_plugin.merge.return_value = fake_result
288 mock_resolve.return_value = mock_plugin
289
290 result = runner.invoke(cli, ["merge", "feature"])
291 assert result.exit_code == 0
292 assert "Merged" in result.output
293
294 def test_merge_conflict_uses_plugin_conflict_paths(self, repo: pathlib.Path) -> None:
295 _write(repo, "beat.mid", "original")
296 _commit("base")
297
298 runner.invoke(cli, ["branch", "feature"])
299 runner.invoke(cli, ["checkout", "feature"])
300 _write(repo, "beat.mid", "feature-version")
301 _commit("feature changes beat")
302
303 runner.invoke(cli, ["checkout", "main"])
304 _write(repo, "beat.mid", "main-version")
305 _commit("main changes beat")
306
307 result = runner.invoke(cli, ["merge", "feature"])
308 assert result.exit_code != 0
309 assert "beat.mid" in result.output
310
311 def test_merge_conflict_paths_come_from_plugin(self, repo: pathlib.Path) -> None:
312 _write(repo, "beat.mid", "original")
313 _commit("base")
314 runner.invoke(cli, ["branch", "feature"])
315 runner.invoke(cli, ["checkout", "feature"])
316 _write(repo, "beat.mid", "feature-version")
317 _commit("feature")
318 runner.invoke(cli, ["checkout", "main"])
319 _write(repo, "beat.mid", "main-version")
320 _commit("main")
321
322 fake_result = MergeResult(
323 merged=SnapshotManifest(files={}, domain="music"),
324 conflicts=["plugin-conflict.mid"],
325 )
326 with patch("muse.cli.commands.merge.resolve_plugin") as mock_resolve:
327 mock_plugin = MagicMock(spec=MuseDomainPlugin)
328 mock_plugin.merge.return_value = fake_result
329 mock_resolve.return_value = mock_plugin
330
331 result = runner.invoke(cli, ["merge", "feature"])
332 assert result.exit_code != 0
333 assert "plugin-conflict.mid" in result.output
334
335
336 # ---------------------------------------------------------------------------
337 # cherry-pick
338 # ---------------------------------------------------------------------------
339
340
341 class TestCherryPickDispatch:
342 def test_cherry_pick_calls_plugin_merge(self, repo: pathlib.Path) -> None:
343 _write(repo, "beat.mid", "v1")
344 _commit("initial")
345
346 runner.invoke(cli, ["branch", "feature"])
347 runner.invoke(cli, ["checkout", "feature"])
348 _write(repo, "lead.mid", "solo")
349 _commit("add lead on feature")
350
351 from muse.core.store import get_head_commit_id
352 from muse.core.repo import require_repo
353 import os
354 os.chdir(repo)
355 feature_tip = get_head_commit_id(repo, "feature")
356 assert feature_tip is not None
357
358 runner.invoke(cli, ["checkout", "main"])
359
360 with patch("muse.cli.commands.cherry_pick.resolve_plugin") as mock_resolve:
361 real_plugin = MusicPlugin()
362 mock_plugin = MagicMock(spec=MuseDomainPlugin)
363 mock_plugin.merge.side_effect = real_plugin.merge
364 mock_resolve.return_value = mock_plugin
365
366 result = runner.invoke(cli, ["cherry-pick", feature_tip])
367 assert result.exit_code == 0, result.output
368 mock_plugin.merge.assert_called_once()
369
370 def test_cherry_pick_three_way_args_are_snapshot_manifests(
371 self, repo: pathlib.Path
372 ) -> None:
373 _write(repo, "beat.mid", "v1")
374 _commit("initial")
375
376 runner.invoke(cli, ["branch", "feature"])
377 runner.invoke(cli, ["checkout", "feature"])
378 _write(repo, "lead.mid", "solo")
379 _commit("add lead")
380
381 import os
382 os.chdir(repo)
383 from muse.core.store import get_head_commit_id
384 feature_tip = get_head_commit_id(repo, "feature")
385 assert feature_tip is not None
386
387 runner.invoke(cli, ["checkout", "main"])
388
389 captured_args: list[tuple[StateSnapshot, StateSnapshot, StateSnapshot]] = []
390 with patch("muse.cli.commands.cherry_pick.resolve_plugin") as mock_resolve:
391 real_plugin = MusicPlugin()
392 mock_plugin = MagicMock(spec=MuseDomainPlugin)
393
394 def cap_merge(
395 base: StateSnapshot, left: StateSnapshot, right: StateSnapshot
396 ) -> MergeResult:
397 captured_args.append((base, left, right))
398 return real_plugin.merge(base, left, right)
399
400 mock_plugin.merge.side_effect = cap_merge
401 mock_resolve.return_value = mock_plugin
402
403 runner.invoke(cli, ["cherry-pick", feature_tip])
404 assert len(captured_args) == 1
405 base, left, right = captured_args[0]
406 assert isinstance(base, dict) and "files" in base
407 assert isinstance(left, dict) and "files" in left
408 assert isinstance(right, dict) and "files" in right
409
410
411 # ---------------------------------------------------------------------------
412 # stash
413 # ---------------------------------------------------------------------------
414
415
416 class TestStashDispatch:
417 def test_stash_calls_plugin_snapshot(self, repo: pathlib.Path) -> None:
418 _write(repo, "beat.mid")
419 _commit()
420 _write(repo, "unsaved.mid", "wip")
421
422 with patch("muse.cli.commands.stash.resolve_plugin") as mock_resolve:
423 real_plugin = MusicPlugin()
424 mock_plugin = MagicMock(spec=MuseDomainPlugin)
425 mock_plugin.snapshot.side_effect = real_plugin.snapshot
426 mock_resolve.return_value = mock_plugin
427
428 result = runner.invoke(cli, ["stash"])
429 assert result.exit_code == 0
430 mock_plugin.snapshot.assert_called_once()
431
432 def test_stash_snapshot_argument_is_workdir_path(self, repo: pathlib.Path) -> None:
433 _write(repo, "beat.mid")
434 _commit()
435 _write(repo, "unsaved.mid", "wip")
436
437 captured: list[LiveState] = []
438 with patch("muse.cli.commands.stash.resolve_plugin") as mock_resolve:
439 real_plugin = MusicPlugin()
440 mock_plugin = MagicMock(spec=MuseDomainPlugin)
441
442 def cap_snapshot(ls: LiveState) -> SnapshotManifest:
443 captured.append(ls)
444 return real_plugin.snapshot(ls)
445
446 mock_plugin.snapshot.side_effect = cap_snapshot
447 mock_resolve.return_value = mock_plugin
448
449 runner.invoke(cli, ["stash"])
450 assert len(captured) == 1
451 assert isinstance(captured[0], pathlib.Path)