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