gabriel / muse public
test_phase1_cohen_action_labels.py python
211 lines 9.0 KB
Raw
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 138 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
17 import hashlib
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 long_id
25
26
27 # ---------------------------------------------------------------------------
28 # Helpers
29 # ---------------------------------------------------------------------------
30
31 def _oid(content: bytes) -> str:
32 return long_id(hashlib.sha256(content).hexdigest())
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: dict[str, bytes]) -> dict:
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 1 commit
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 138 days ago