test_structured_delta.py
python
| 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 | - MusicPlugin.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.music.plugin import MusicPlugin, plugin |
| 31 | |
| 32 | |
| 33 | # --------------------------------------------------------------------------- |
| 34 | # Helpers |
| 35 | # --------------------------------------------------------------------------- |
| 36 | |
| 37 | def _snap(files: dict[str, str]) -> SnapshotManifest: |
| 38 | return SnapshotManifest(files=files, domain="music") |
| 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="music", |
| 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"] == "music" |
| 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"] == "music" |
| 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 | # MusicPlugin.diff() returns StructuredDelta |
| 188 | # --------------------------------------------------------------------------- |
| 189 | |
| 190 | class TestMusicPluginStructuredDiff: |
| 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"] == "music" |
| 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 | # MusicPlugin.apply() handles StructuredDelta |
| 332 | # --------------------------------------------------------------------------- |
| 333 | |
| 334 | class TestMusicPluginApply: |
| 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="music", |
| 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="music", |
| 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="music", |
| 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="music", 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", "music"], 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 |