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