gabriel / muse public
test_phase1_cohen_action_labels.py python
211 lines 9.0 KB
Raw
sha256:c0eba5ad689cec79f4a3fcdc4f5da78556cb4b8cb7b330f944634356c10379ed chore: pivot to nightly channel — bump version to 0.2.0.dev… Sonnet 5 patch 12 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 10 commits
sha256:c287f599c5429903a139eadf3c5db5d930520e57cb0c3c575d9570e953c3b2d6 chore: bump version to 0.2.0.dev2 — nightly.2 Sonnet 4.6 patch 11 days ago
sha256:8de4334a98c945aace420969d389ad678aa926d4ab4e886b2ac4c4241cb3bf2b revert: keep pyproject.toml in canonical PEP 440 form Sonnet 4.6 patch 14 days ago
sha256:50bb615c573aa4b928fca75b60f82ba2a659910066507cec6a95c412ae22ccb9 docs: add issue docs for push have-negotiation bug (#55) an… Sonnet 4.6 18 days ago
sha256:d90d175cded68aae1d4ffcf4858917854195d0cd8ce1fe73cee4dbc02541cb74 chore: trigger push to surface null-OID paths Sonnet 4.6 23 days ago
sha256:e452ad9a6ace6ccc6d875a35e06caf9da5576a970c1c36133b69a891ce5fefa8 chore: prebuild timing test Sonnet 4.6 34 days ago
sha256:0008ab6695e3e064b3e236b24fd19e538fef6a588eb0d211622f4466d919c0b1 merge: pull staging/dev — advance to 0.2.0rc12 Sonnet 4.6 patch 35 days ago
sha256:9c33d61749fff814c5226d5386aa2af7064c2c02788594a25fdd709358132eea fix: _PROPOSAL_PREFIX_RESOLVE_LIMIT 200 → 100 to match hub … Sonnet 4.6 47 days ago
sha256:36c3cb3e76619d4c30a6d9bf81b5ec4ff148e30dcfed913e3114ca7b43b81c7e fix: rename objects→blobs in push client and all stale test… Sonnet 4.6 patch 49 days ago
sha256:c06a9b9b9fee26c68ea725b44d54b2c0a171301ce9de746d5b656617b4463a9a fix: repair four test failures from post-migration audit Sonnet 4.6 patch 56 days ago
sha256:1900655993c83c4107067375548a7be823e471d2515830842f1a12cba4bd3cdf fix: unified object store migration — idempotent writes, JS… Sonnet 4.6 minor 56 days ago