gabriel / muse public
test_phase1_cohen_action_labels.py python
211 lines 9.0 KB
Raw
sha256:81ae324db5ad375fbfe4834c6fcb378312cafad3cc92dec5d3e5c427306621a2 fix: remove commit_exists filter from have anchors — server… Sonnet 4.6 patch 21 days ago
1 """Phase 1 — Cohen action labels in merge op stream JSON.
2
3 Every conflict record now carries ``ours_action`` and ``theirs_action``
4 fields telling agents *what each side did* rather than just *that they
5 conflicted*. Values are the Cohen-transform labels: "inserted", "deleted",
6 "modified".
7
8 Test categories
9 ---------------
10 TestConflictRecordSchema — ConflictRecord and ConflictDict have the new fields
11 TestCodePluginMergeLabels — plugin.merge() populates action labels on conflict_records
12 TestMergeJsonOutput — muse merge --json includes action labels in conflict output
13 """
14
15 from __future__ import annotations
16 from collections.abc import Mapping
17
18 import pathlib
19
20 import pytest
21
22 from muse.domain import ConflictRecord
23 from muse.plugins.code.plugin import CodePlugin
24 from muse.core.types import blob_id
25
26
27 # ---------------------------------------------------------------------------
28 # Helpers
29 # ---------------------------------------------------------------------------
30
31 def _oid(content: bytes) -> str:
32 return blob_id(content)
33
34
35 def _write_blob(root: pathlib.Path, content: bytes) -> str:
36 from muse.core.object_store import write_object
37 oid = _oid(content)
38 write_object(root, oid, content)
39 return oid
40
41
42 def _snap(root: pathlib.Path, files: Mapping[str, bytes]) -> Mapping[str, object]:
43 return {
44 "files": {path: _write_blob(root, content) for path, content in files.items()},
45 "domain": "code",
46 "directories": [],
47 }
48
49
50 _BASE_PY = b"def existing(): pass\n"
51 _OURS_PY = b"def existing(): pass\ndef added_by_ours(): pass\n"
52 _THEIRS_PY = b"def existing(): pass\ndef added_by_theirs(): pass\n"
53 _OURS_MODIFIED = b"def existing(): return 1\n"
54 _THEIRS_MODIFIED = b"def existing(): return 2\n"
55
56
57 # ---------------------------------------------------------------------------
58 # TestConflictRecordSchema
59 # ---------------------------------------------------------------------------
60
61 class TestConflictRecordSchema:
62 """ConflictRecord and its dict form must carry the new action fields."""
63
64 def test_conflict_record_has_ours_action(self) -> None:
65 rec = ConflictRecord(path="src/a.py")
66 assert hasattr(rec, "ours_action"), "ConflictRecord missing ours_action field"
67
68 def test_conflict_record_has_theirs_action(self) -> None:
69 rec = ConflictRecord(path="src/a.py")
70 assert hasattr(rec, "theirs_action"), "ConflictRecord missing theirs_action field"
71
72 def test_conflict_record_defaults_empty_string(self) -> None:
73 rec = ConflictRecord(path="src/a.py")
74 assert rec.ours_action == ""
75 assert rec.theirs_action == ""
76
77 def test_conflict_record_explicit_actions(self) -> None:
78 rec = ConflictRecord(path="src/a.py", ours_action="deleted", theirs_action="modified")
79 assert rec.ours_action == "deleted"
80 assert rec.theirs_action == "modified"
81
82 def test_to_dict_includes_ours_action(self) -> None:
83 rec = ConflictRecord(path="src/a.py", ours_action="inserted", theirs_action="modified")
84 d = rec.to_dict()
85 assert "ours_action" in d, "to_dict() missing ours_action"
86 assert d["ours_action"] == "inserted"
87
88 def test_to_dict_includes_theirs_action(self) -> None:
89 rec = ConflictRecord(path="src/a.py", ours_action="modified", theirs_action="deleted")
90 d = rec.to_dict()
91 assert "theirs_action" in d, "to_dict() missing theirs_action"
92 assert d["theirs_action"] == "deleted"
93
94 def test_all_action_values_accepted(self) -> None:
95 for action in ("inserted", "deleted", "modified"):
96 rec = ConflictRecord(path="a.py", ours_action=action, theirs_action=action)
97 assert rec.ours_action == action
98 assert rec.theirs_action == action
99
100
101 # ---------------------------------------------------------------------------
102 # TestCodePluginMergeLabels
103 # ---------------------------------------------------------------------------
104
105 class TestCodePluginMergeLabels:
106 """CodePlugin.merge() must populate conflict_records with action labels."""
107
108 def test_both_modified_produces_modified_labels(self, tmp_path: pathlib.Path) -> None:
109 """Both sides changed the same file from the same base → both 'modified'."""
110 plugin = CodePlugin()
111 base = _snap(tmp_path, {"src/a.py": _BASE_PY})
112 ours = _snap(tmp_path, {"src/a.py": _OURS_MODIFIED})
113 theirs = _snap(tmp_path, {"src/a.py": _THEIRS_MODIFIED})
114
115 result = plugin.merge(base, ours, theirs)
116
117 assert "src/a.py" in result.conflicts
118 recs = {r.path: r for r in result.conflict_records}
119 assert "src/a.py" in recs, "conflict_records must include the conflicting path"
120 assert recs["src/a.py"].ours_action == "modified"
121 assert recs["src/a.py"].theirs_action == "modified"
122
123 def test_ours_deleted_theirs_modified_labels(self, tmp_path: pathlib.Path) -> None:
124 """Ours deleted, theirs modified → ours='deleted', theirs='modified'."""
125 plugin = CodePlugin()
126 base = _snap(tmp_path, {"src/a.py": _BASE_PY})
127 ours = _snap(tmp_path, {}) # ours deleted the file
128 theirs = _snap(tmp_path, {"src/a.py": _THEIRS_MODIFIED})
129
130 result = plugin.merge(base, ours, theirs)
131
132 assert "src/a.py" in result.conflicts
133 recs = {r.path: r for r in result.conflict_records}
134 assert recs["src/a.py"].ours_action == "deleted"
135 assert recs["src/a.py"].theirs_action == "modified"
136
137 def test_ours_modified_theirs_deleted_labels(self, tmp_path: pathlib.Path) -> None:
138 """Ours modified, theirs deleted → ours='modified', theirs='deleted'."""
139 plugin = CodePlugin()
140 base = _snap(tmp_path, {"src/a.py": _BASE_PY})
141 ours = _snap(tmp_path, {"src/a.py": _OURS_MODIFIED})
142 theirs = _snap(tmp_path, {}) # theirs deleted the file
143
144 result = plugin.merge(base, ours, theirs)
145
146 assert "src/a.py" in result.conflicts
147 recs = {r.path: r for r in result.conflict_records}
148 assert recs["src/a.py"].ours_action == "modified"
149 assert recs["src/a.py"].theirs_action == "deleted"
150
151 def test_both_inserted_different_content_labels(self, tmp_path: pathlib.Path) -> None:
152 """Both sides created the same path from nothing → both 'inserted'."""
153 plugin = CodePlugin()
154 base = _snap(tmp_path, {}) # file doesn't exist at base
155 ours = _snap(tmp_path, {"src/new.py": _OURS_MODIFIED})
156 theirs = _snap(tmp_path, {"src/new.py": _THEIRS_MODIFIED})
157
158 result = plugin.merge(base, ours, theirs)
159
160 assert "src/new.py" in result.conflicts
161 recs = {r.path: r for r in result.conflict_records}
162 assert recs["src/new.py"].ours_action == "inserted"
163 assert recs["src/new.py"].theirs_action == "inserted"
164
165 def test_no_conflict_no_conflict_records(self, tmp_path: pathlib.Path) -> None:
166 """Clean merge must not produce spurious conflict_records."""
167 plugin = CodePlugin()
168 base = _snap(tmp_path, {"src/a.py": _BASE_PY})
169 ours = _snap(tmp_path, {"src/a.py": _OURS_MODIFIED})
170 theirs = _snap(tmp_path, {"src/a.py": _BASE_PY}) # theirs unchanged
171
172 result = plugin.merge(base, ours, theirs)
173
174 assert result.conflicts == []
175 assert result.conflict_records == []
176
177 def test_multiple_conflicts_each_gets_record(self, tmp_path: pathlib.Path) -> None:
178 """Each conflicting path gets its own ConflictRecord."""
179 plugin = CodePlugin()
180 base = _snap(tmp_path, {"a.py": _BASE_PY, "b.py": _BASE_PY})
181 ours = _snap(tmp_path, {"a.py": _OURS_MODIFIED, "b.py": _OURS_MODIFIED})
182 theirs = _snap(tmp_path, {"a.py": _THEIRS_MODIFIED, "b.py": _THEIRS_MODIFIED})
183
184 result = plugin.merge(base, ours, theirs)
185
186 assert len(result.conflicts) == 2
187 assert len(result.conflict_records) == 2
188 paths = {r.path for r in result.conflict_records}
189 assert "a.py" in paths
190 assert "b.py" in paths
191
192 def test_manual_strategy_conflict_record_has_labels(self, tmp_path: pathlib.Path) -> None:
193 """Manual-strategy forced conflicts also get action labels."""
194 attrs_path = tmp_path / ".museattributes"
195 attrs_path.write_text(
196 '[meta]\ndomain = "code"\n\n'
197 '[[rules]]\npath = "locked/**"\ndimension = "*"\nstrategy = "manual"\npriority = 10\n'
198 )
199 plugin = CodePlugin()
200 base = _snap(tmp_path, {"locked/cfg.py": _BASE_PY})
201 ours = _snap(tmp_path, {"locked/cfg.py": _BASE_PY}) # ours unchanged (b==l)
202 theirs = _snap(tmp_path, {"locked/cfg.py": _THEIRS_MODIFIED}) # theirs changed
203
204 result = plugin.merge(base, ours, theirs, repo_root=tmp_path)
205
206 assert "locked/cfg.py" in result.conflicts
207 recs = {r.path: r for r in result.conflict_records}
208 # theirs_action: theirs changed from base → "modified"
209 # ours_action: ours == base → effectively "unmodified" (no ours change)
210 # Convention: ours_action for b==l case = "" or explicit label for the forced-manual case
211 assert recs["locked/cfg.py"].theirs_action == "modified"
File History 4 commits
sha256:81ae324db5ad375fbfe4834c6fcb378312cafad3cc92dec5d3e5c427306621a2 fix: remove commit_exists filter from have anchors — server… Sonnet 4.6 patch 21 days ago
sha256:36c3cb3e76619d4c30a6d9bf81b5ec4ff148e30dcfed913e3114ca7b43b81c7e fix: rename objects→blobs in push client and all stale test… Sonnet 4.6 patch 23 days ago
sha256:c06a9b9b9fee26c68ea725b44d54b2c0a171301ce9de746d5b656617b4463a9a fix: repair four test failures from post-migration audit Sonnet 4.6 patch 29 days ago
sha256:1900655993c83c4107067375548a7be823e471d2515830842f1a12cba4bd3cdf fix: unified object store migration — idempotent writes, JS… Sonnet 4.6 minor 29 days ago