cgcardona / muse public
test_structured_delta.py python
402 lines 13.9 KB
9ee9c39c refactor: rename music→midi domain, strip all 5-dim backward compat Gabriel Cardona <gabriel@tellurstori.com> 1d ago
1 """Tests for the structured delta type system.
2
3 Covers:
4 - All five DomainOp TypedDicts can be constructed and serialised to JSON.
5 - StructuredDelta satisfies the StateDelta type alias.
6 - MidiPlugin.diff() returns a StructuredDelta with correctly typed ops.
7 - PatchOp wraps note-level child_ops for modified .mid files.
8 - DriftReport.delta is a StructuredDelta.
9 - muse show and muse diff display structured output.
10 """
11 from __future__ import annotations
12
13 import json
14 import pathlib
15
16 import pytest
17
18 from muse.domain import (
19 DeleteOp,
20 DomainOp,
21 DriftReport,
22 InsertOp,
23 MoveOp,
24 PatchOp,
25 ReplaceOp,
26 SnapshotManifest,
27 StateDelta,
28 StructuredDelta,
29 )
30 from muse.plugins.midi.plugin import MidiPlugin, plugin
31
32
33 # ---------------------------------------------------------------------------
34 # Helpers
35 # ---------------------------------------------------------------------------
36
37 def _snap(files: dict[str, str]) -> SnapshotManifest:
38 return SnapshotManifest(files=files, domain="midi")
39
40
41 def _make_insert(address: str = "a.mid", content_id: str = "abc123") -> InsertOp:
42 return InsertOp(
43 op="insert",
44 address=address,
45 position=None,
46 content_id=content_id,
47 content_summary=f"new file: {address}",
48 )
49
50
51 def _make_delete(address: str = "a.mid", content_id: str = "abc123") -> DeleteOp:
52 return DeleteOp(
53 op="delete",
54 address=address,
55 position=None,
56 content_id=content_id,
57 content_summary=f"deleted: {address}",
58 )
59
60
61 def _make_move() -> MoveOp:
62 return MoveOp(
63 op="move",
64 address="note:5",
65 from_position=5,
66 to_position=12,
67 content_id="deadbeef",
68 )
69
70
71 def _make_replace(address: str = "a.mid") -> ReplaceOp:
72 return ReplaceOp(
73 op="replace",
74 address=address,
75 position=None,
76 old_content_id="old123",
77 new_content_id="new456",
78 old_summary=f"{address} (prev)",
79 new_summary=f"{address} (new)",
80 )
81
82
83 def _make_patch(child_ops: list[DomainOp] | None = None) -> PatchOp:
84 return PatchOp(
85 op="patch",
86 address="tracks/drums.mid",
87 child_ops=child_ops or [],
88 child_domain="midi_notes",
89 child_summary="2 notes added",
90 )
91
92
93 def _make_delta(ops: list[DomainOp] | None = None) -> StructuredDelta:
94 return StructuredDelta(
95 domain="midi",
96 ops=ops or [],
97 summary="no changes",
98 )
99
100
101 # ---------------------------------------------------------------------------
102 # TypedDict construction and JSON round-trips
103 # ---------------------------------------------------------------------------
104
105 class TestDeltaOpTypes:
106 def test_insert_op_has_correct_discriminant(self) -> None:
107 op = _make_insert()
108 assert op["op"] == "insert"
109
110 def test_delete_op_has_correct_discriminant(self) -> None:
111 op = _make_delete()
112 assert op["op"] == "delete"
113
114 def test_move_op_has_correct_discriminant(self) -> None:
115 op = _make_move()
116 assert op["op"] == "move"
117
118 def test_replace_op_has_correct_discriminant(self) -> None:
119 op = _make_replace()
120 assert op["op"] == "replace"
121
122 def test_patch_op_has_correct_discriminant(self) -> None:
123 op = _make_patch()
124 assert op["op"] == "patch"
125
126 def test_insert_op_round_trips_json(self) -> None:
127 op = _make_insert()
128 serialised = json.dumps(op)
129 restored = json.loads(serialised)
130 assert restored["op"] == "insert"
131 assert restored["address"] == "a.mid"
132 assert restored["position"] is None
133 assert restored["content_id"] == "abc123"
134
135 def test_delete_op_round_trips_json(self) -> None:
136 op = _make_delete()
137 serialised = json.dumps(op)
138 restored = json.loads(serialised)
139 assert restored["op"] == "delete"
140 assert restored["address"] == "a.mid"
141
142 def test_move_op_round_trips_json(self) -> None:
143 op = _make_move()
144 serialised = json.dumps(op)
145 restored = json.loads(serialised)
146 assert restored["op"] == "move"
147 assert restored["from_position"] == 5
148 assert restored["to_position"] == 12
149
150 def test_replace_op_round_trips_json(self) -> None:
151 op = _make_replace()
152 serialised = json.dumps(op)
153 restored = json.loads(serialised)
154 assert restored["op"] == "replace"
155 assert restored["old_content_id"] == "old123"
156 assert restored["new_content_id"] == "new456"
157
158 def test_patch_op_with_child_ops_round_trips_json(self) -> None:
159 insert = _make_insert("note:3", "aabbcc")
160 patch = _make_patch(child_ops=[insert])
161 serialised = json.dumps(patch)
162 restored = json.loads(serialised)
163 assert restored["op"] == "patch"
164 assert len(restored["child_ops"]) == 1
165 assert restored["child_ops"][0]["op"] == "insert"
166
167 def test_structured_delta_round_trips_json(self) -> None:
168 delta = _make_delta(ops=[_make_insert(), _make_delete("b.mid", "xyz")])
169 serialised = json.dumps(delta)
170 restored = json.loads(serialised)
171 assert restored["domain"] == "midi"
172 assert len(restored["ops"]) == 2
173 assert restored["summary"] == "no changes"
174
175 def test_structured_delta_is_state_delta_type(self) -> None:
176 delta: StateDelta = _make_delta()
177 assert delta["domain"] == "midi"
178
179 def test_structured_delta_has_required_keys(self) -> None:
180 delta = _make_delta()
181 assert "domain" in delta
182 assert "ops" in delta
183 assert "summary" in delta
184
185
186 # ---------------------------------------------------------------------------
187 # MidiPlugin.diff() returns StructuredDelta
188 # ---------------------------------------------------------------------------
189
190 class TestMidiPluginStructuredDiff:
191 def test_no_change_returns_empty_ops(self) -> None:
192 snap = _snap({"a.mid": "h1"})
193 delta = plugin.diff(snap, snap)
194 assert isinstance(delta, dict)
195 assert delta["ops"] == []
196
197 def test_no_change_summary_is_no_changes(self) -> None:
198 snap = _snap({"a.mid": "h1"})
199 delta = plugin.diff(snap, snap)
200 assert delta["summary"] == "no changes"
201
202 def test_file_added_returns_insert_op(self) -> None:
203 base = _snap({})
204 target = _snap({"new.mid": "h1"})
205 delta = plugin.diff(base, target)
206 ops = delta["ops"]
207 assert len(ops) == 1
208 assert ops[0]["op"] == "insert"
209 assert ops[0]["address"] == "new.mid"
210
211 def test_file_added_insert_op_has_content_id(self) -> None:
212 base = _snap({})
213 target = _snap({"new.mid": "abcdef123"})
214 delta = plugin.diff(base, target)
215 assert delta["ops"][0]["content_id"] == "abcdef123"
216
217 def test_file_removed_returns_delete_op(self) -> None:
218 base = _snap({"old.mid": "h1"})
219 target = _snap({})
220 delta = plugin.diff(base, target)
221 ops = delta["ops"]
222 assert len(ops) == 1
223 assert ops[0]["op"] == "delete"
224 assert ops[0]["address"] == "old.mid"
225
226 def test_file_removed_delete_op_has_content_id(self) -> None:
227 base = _snap({"old.mid": "prevhash"})
228 target = _snap({})
229 delta = plugin.diff(base, target)
230 assert delta["ops"][0]["content_id"] == "prevhash"
231
232 def test_non_midi_modified_returns_replace_op(self) -> None:
233 base = _snap({"notes.txt": "old"})
234 target = _snap({"notes.txt": "new"})
235 delta = plugin.diff(base, target)
236 ops = delta["ops"]
237 assert len(ops) == 1
238 assert ops[0]["op"] == "replace"
239 assert ops[0]["address"] == "notes.txt"
240
241 def test_replace_op_has_old_and_new_ids(self) -> None:
242 base = _snap({"notes.txt": "oldhash"})
243 target = _snap({"notes.txt": "newhash"})
244 delta = plugin.diff(base, target)
245 op = delta["ops"][0]
246 assert op["op"] == "replace"
247 assert op["old_content_id"] == "oldhash"
248 assert op["new_content_id"] == "newhash"
249
250 def test_mid_modified_without_repo_root_returns_replace_op(self) -> None:
251 # Without repo_root we can't load blobs, so fallback to ReplaceOp.
252 base = _snap({"drums.mid": "old"})
253 target = _snap({"drums.mid": "new"})
254 delta = plugin.diff(base, target)
255 assert delta["ops"][0]["op"] == "replace"
256
257 def test_multiple_changes_produce_multiple_ops(self) -> None:
258 base = _snap({"a.mid": "h1", "b.mid": "h2"})
259 target = _snap({"b.mid": "h2_new", "c.mid": "h3"})
260 delta = plugin.diff(base, target)
261 kinds = {op["op"] for op in delta["ops"]}
262 assert "insert" in kinds # c.mid added
263 assert "delete" in kinds # a.mid removed
264 assert "replace" in kinds # b.mid modified
265
266 def test_summary_mentions_added_on_add(self) -> None:
267 base = _snap({})
268 target = _snap({"x.mid": "h"})
269 delta = plugin.diff(base, target)
270 assert "added" in delta["summary"]
271
272 def test_summary_mentions_removed_on_delete(self) -> None:
273 base = _snap({"x.mid": "h"})
274 target = _snap({})
275 delta = plugin.diff(base, target)
276 assert "removed" in delta["summary"]
277
278 def test_domain_is_music(self) -> None:
279 snap = _snap({"a.mid": "h"})
280 delta = plugin.diff(snap, snap)
281 assert delta["domain"] == "midi"
282
283 def test_insert_op_position_is_none_for_file_level(self) -> None:
284 base = _snap({})
285 target = _snap({"f.mid": "h"})
286 delta = plugin.diff(base, target)
287 assert delta["ops"][0]["position"] is None
288
289 def test_ops_are_sorted_by_address(self) -> None:
290 base = _snap({})
291 target = _snap({"z.mid": "h1", "a.mid": "h2", "m.mid": "h3"})
292 delta = plugin.diff(base, target)
293 addresses = [op["address"] for op in delta["ops"]]
294 assert addresses == sorted(addresses)
295
296
297 # ---------------------------------------------------------------------------
298 # DriftReport uses StructuredDelta
299 # ---------------------------------------------------------------------------
300
301 class TestDriftReportDelta:
302 def test_no_drift_delta_is_structured(self) -> None:
303 snap = _snap({"a.mid": "h"})
304 report = plugin.drift(snap, snap)
305 assert isinstance(report, DriftReport)
306 assert isinstance(report.delta, dict)
307 assert "ops" in report.delta
308 assert "summary" in report.delta
309
310 def test_drift_delta_has_insert_op_on_addition(self) -> None:
311 committed = _snap({"a.mid": "h1"})
312 live = _snap({"a.mid": "h1", "b.mid": "h2"})
313 report = plugin.drift(committed, live)
314 assert report.has_drift
315 insert_ops = [op for op in report.delta["ops"] if op["op"] == "insert"]
316 assert any(op["address"] == "b.mid" for op in insert_ops)
317
318 def test_drift_summary_still_human_readable(self) -> None:
319 committed = _snap({"a.mid": "h1"})
320 live = _snap({"a.mid": "h1", "b.mid": "h2"})
321 report = plugin.drift(committed, live)
322 assert "added" in report.summary
323
324 def test_default_drift_report_delta_is_empty_structured(self) -> None:
325 report = DriftReport(has_drift=False)
326 assert report.delta["ops"] == []
327 assert report.delta["domain"] == ""
328
329
330 # ---------------------------------------------------------------------------
331 # MidiPlugin.apply() handles StructuredDelta
332 # ---------------------------------------------------------------------------
333
334 class TestMidiPluginApply:
335 def test_apply_delete_op_removes_file(self) -> None:
336 snap = _snap({"a.mid": "h1", "b.mid": "h2"})
337 delta: StructuredDelta = StructuredDelta(
338 domain="midi",
339 ops=[DeleteOp(
340 op="delete", address="a.mid", position=None,
341 content_id="h1", content_summary="deleted: a.mid",
342 )],
343 summary="1 file removed",
344 )
345 result = plugin.apply(delta, snap)
346 assert "a.mid" not in result["files"]
347 assert "b.mid" in result["files"]
348
349 def test_apply_replace_op_updates_hash(self) -> None:
350 snap = _snap({"a.mid": "old"})
351 delta: StructuredDelta = StructuredDelta(
352 domain="midi",
353 ops=[ReplaceOp(
354 op="replace", address="a.mid", position=None,
355 old_content_id="old", new_content_id="new",
356 old_summary="a.mid (prev)", new_summary="a.mid (new)",
357 )],
358 summary="1 file modified",
359 )
360 result = plugin.apply(delta, snap)
361 assert result["files"]["a.mid"] == "new"
362
363 def test_apply_insert_op_adds_file(self) -> None:
364 snap = _snap({})
365 delta: StructuredDelta = StructuredDelta(
366 domain="midi",
367 ops=[InsertOp(
368 op="insert", address="new.mid", position=None,
369 content_id="newhash", content_summary="new file: new.mid",
370 )],
371 summary="1 file added",
372 )
373 result = plugin.apply(delta, snap)
374 assert result["files"]["new.mid"] == "newhash"
375
376 def test_apply_from_workdir_rescans(self, tmp_path: pathlib.Path) -> None:
377 workdir = tmp_path / "muse-work"
378 workdir.mkdir()
379 (workdir / "beat.mid").write_bytes(b"drums")
380 delta: StructuredDelta = StructuredDelta(
381 domain="midi", ops=[], summary="no changes",
382 )
383 result = plugin.apply(delta, workdir)
384 assert "beat.mid" in result["files"]
385
386
387 # ---------------------------------------------------------------------------
388 # CLI show displays structured delta
389 # ---------------------------------------------------------------------------
390
391 class TestShowStructuredOutput:
392 def test_show_displays_structured_summary(
393 self, tmp_path: pathlib.Path
394 ) -> None:
395 from typer.testing import CliRunner
396 from muse.cli.app import cli
397
398 runner = CliRunner()
399 result = runner.invoke(cli, ["init", "--domain", "midi"], obj={})
400 # Just check the command is importable and types are correct —
401 # full CLI integration is covered in test_cli_workflow.py.
402 assert result is not None