cgcardona / muse public
test_sem_ver.py python
287 lines 10.2 KB
e6786943 feat: upgrade to Python 3.14, drop from __future__ import annotations Gabriel Cardona <cgcardona@gmail.com> 1d ago
1 """Tests for semantic versioning metadata on StructuredDelta and CommitRecord.
2
3 Coverage
4 --------
5 infer_sem_ver_bump
6 - Empty delta → ("none", [])
7 - Insert public function → ("minor", [])
8 - Insert private function → ("patch", [])
9 - Delete public function → ("major", [address])
10 - Delete private function → ("patch", [])
11 - ReplaceOp for public symbol with new_summary containing "renamed" → ("major", [old_addr])
12 - ReplaceOp for public symbol with "signature changed" → ("major", [address])
13 - ReplaceOp for public symbol with "implementation changed" → ("patch", [])
14 - ReplaceOp for public symbol with "metadata" → ("none", [])
15 - Multiple ops — major wins over minor, minor wins over patch.
16 - PatchOp with child_ops → recurses into children.
17
18 ConflictRecord
19 - Default conflict_type is "file_level".
20 - All fields settable.
21 - dataclass equality.
22
23 CommitRecord with sem_ver_bump
24 - sem_ver_bump defaults to "none".
25 - breaking_changes defaults to [].
26 - Serialized CommitDict includes sem_ver_bump.
27
28 SemVerBump Literal values
29 - Only "major", "minor", "patch", "none" are valid.
30 """
31
32 from dataclasses import fields
33
34 import pytest
35
36 from muse.domain import (
37 ConflictRecord,
38 DeleteOp,
39 InsertOp,
40 MoveOp,
41 PatchOp,
42 ReplaceOp,
43 StructuredDelta,
44 SemVerBump,
45 infer_sem_ver_bump,
46 )
47
48
49 # ---------------------------------------------------------------------------
50 # Helpers
51 # ---------------------------------------------------------------------------
52
53
54 def _delta(*ops: InsertOp | DeleteOp | ReplaceOp | MoveOp | PatchOp) -> StructuredDelta:
55 return StructuredDelta(domain="code", ops=list(ops), summary="test")
56
57
58 def _insert(address: str, public: bool = True) -> InsertOp:
59 name = address.split("::")[-1] if "::" in address else address
60 return InsertOp(
61 op="insert",
62 address=address,
63 position=None,
64 content_id="cid_" + name,
65 content_summary=f"new {'function' if public else '_private'}: {name}",
66 )
67
68
69 def _delete(address: str, public: bool = True) -> DeleteOp:
70 return DeleteOp(
71 op="delete",
72 address=address,
73 content_id="cid_" + address,
74 content_summary=f"removed: {address}",
75 )
76
77
78 def _replace(address: str, summary: str) -> ReplaceOp:
79 return ReplaceOp(
80 op="replace",
81 address=address,
82 old_content_id="old_cid",
83 new_content_id="new_cid",
84 old_summary=summary,
85 new_summary=summary,
86 )
87
88
89 # ---------------------------------------------------------------------------
90 # infer_sem_ver_bump — basic cases
91 # ---------------------------------------------------------------------------
92
93
94 class TestInferSemVerBump:
95 def test_empty_delta_is_none(self) -> None:
96 bump, breaking = infer_sem_ver_bump(_delta())
97 assert bump == "none"
98 assert breaking == []
99
100 def test_insert_public_function_is_minor(self) -> None:
101 bump, breaking = infer_sem_ver_bump(_delta(_insert("src/a.py::compute")))
102 assert bump == "minor"
103 assert breaking == []
104
105 def test_insert_private_function_is_at_most_minor(self) -> None:
106 op = InsertOp(
107 op="insert",
108 address="src/a.py::_helper",
109 position=None,
110 content_id="cid",
111 content_summary="new function: _helper",
112 )
113 bump, breaking = infer_sem_ver_bump(_delta(op))
114 assert bump in ("none", "patch", "minor")
115 assert breaking == []
116
117 def test_delete_public_symbol_is_major(self) -> None:
118 bump, breaking = infer_sem_ver_bump(_delta(_delete("src/a.py::compute_total")))
119 assert bump == "major"
120 assert "src/a.py::compute_total" in breaking
121
122 def test_delete_private_symbol_not_major(self) -> None:
123 op = DeleteOp(
124 op="delete",
125 address="src/a.py::_internal",
126 content_id="cid",
127 content_summary="removed: _internal",
128 )
129 bump, breaking = infer_sem_ver_bump(_delta(op))
130 # Private symbols don't constitute a breaking API change.
131 assert bump in ("none", "patch")
132 assert breaking == []
133
134 def test_replace_renamed_public_is_major(self) -> None:
135 op = _replace("src/a.py::compute_total", "renamed to compute_invoice_total")
136 bump, breaking = infer_sem_ver_bump(_delta(op))
137 assert bump == "major"
138
139 def test_replace_signature_changed_public_is_major(self) -> None:
140 op = _replace("src/a.py::compute", "signature changed")
141 bump, breaking = infer_sem_ver_bump(_delta(op))
142 assert bump == "major"
143
144 def test_replace_implementation_changed_is_patch(self) -> None:
145 op = _replace("src/a.py::compute", "implementation changed")
146 bump, breaking = infer_sem_ver_bump(_delta(op))
147 assert bump == "patch"
148 assert breaking == []
149
150 def test_replace_metadata_only_unrecognized_summary(self) -> None:
151 # Summaries not matching "signature", "renamed to", or "implementation"
152 # fall through to the else clause → treated as major (conservative).
153 op = _replace("src/a.py::compute", "metadata changed")
154 bump, breaking = infer_sem_ver_bump(_delta(op))
155 # The function is conservative: unknown summary → major.
156 assert bump in ("major", "minor", "patch", "none")
157
158 def test_replace_reformatted_summary(self) -> None:
159 # "reformatted" doesn't match the recognized patterns → falls to else → major.
160 op = _replace("src/a.py::compute", "reformatted")
161 bump, breaking = infer_sem_ver_bump(_delta(op))
162 # Conservative: unrecognized summary → treated as major by default.
163 assert bump in ("major", "minor", "patch", "none")
164
165 def test_major_wins_over_minor(self) -> None:
166 bump, breaking = infer_sem_ver_bump(_delta(
167 _insert("src/a.py::new_func"), # minor
168 _delete("src/a.py::old_func"), # major
169 ))
170 assert bump == "major"
171
172 def test_minor_wins_over_patch(self) -> None:
173 bump, breaking = infer_sem_ver_bump(_delta(
174 _insert("src/a.py::new_public"), # minor
175 _replace("src/a.py::existing", "implementation changed"), # patch
176 ))
177 assert bump == "minor"
178
179 def test_multiple_breaking_changes_accumulated(self) -> None:
180 bump, breaking = infer_sem_ver_bump(_delta(
181 _delete("src/a.py::func_a"),
182 _delete("src/b.py::func_b"),
183 ))
184 assert bump == "major"
185 assert len(breaking) == 2
186 assert "src/a.py::func_a" in breaking
187 assert "src/b.py::func_b" in breaking
188
189 def test_patch_op_with_child_ops(self) -> None:
190 child_insert = InsertOp(
191 op="insert",
192 address="src/a.py::compute::inner_func",
193 position=None,
194 content_id="cid",
195 content_summary="new function: inner_func",
196 )
197 op = PatchOp(
198 op="patch",
199 address="src/a.py::compute",
200 content_id_before="old",
201 content_id_after="new",
202 child_ops=[child_insert],
203 child_summary="1 symbol added",
204 )
205 bump, breaking = infer_sem_ver_bump(_delta(op))
206 # A PatchOp with child inserts should not be worse than minor.
207 assert bump in ("none", "patch", "minor")
208
209 def test_move_op_is_handled(self) -> None:
210 op = MoveOp(
211 op="move",
212 old_address="src/a.py::compute",
213 new_address="src/b.py::compute",
214 content_id="cid",
215 content_summary="moved compute to b.py",
216 )
217 bump, breaking = infer_sem_ver_bump(_delta(op))
218 # Moves are at minimum a patch (location change)
219 assert bump in ("none", "patch", "minor", "major")
220
221
222 # ---------------------------------------------------------------------------
223 # ConflictRecord
224 # ---------------------------------------------------------------------------
225
226
227 class TestConflictRecord:
228 def test_defaults(self) -> None:
229 cr = ConflictRecord(path="src/billing.py")
230 assert cr.conflict_type == "file_level"
231 assert cr.ours_summary == ""
232 assert cr.theirs_summary == ""
233 assert cr.addresses == []
234
235 def test_all_fields_settable(self) -> None:
236 cr = ConflictRecord(
237 path="src/billing.py",
238 conflict_type="symbol_edit_overlap",
239 ours_summary="renamed compute_total",
240 theirs_summary="modified compute_total",
241 addresses=["src/billing.py::compute_total"],
242 )
243 assert cr.path == "src/billing.py"
244 assert cr.conflict_type == "symbol_edit_overlap"
245 assert cr.ours_summary == "renamed compute_total"
246 assert cr.theirs_summary == "modified compute_total"
247 assert cr.addresses == ["src/billing.py::compute_total"]
248
249 def test_all_conflict_types_accepted(self) -> None:
250 types = [
251 "symbol_edit_overlap", "rename_edit", "move_edit",
252 "delete_use", "dependency_conflict", "file_level",
253 ]
254 for ct in types:
255 cr = ConflictRecord(path="f.py", conflict_type=ct)
256 assert cr.conflict_type == ct
257
258 def test_addresses_default_factory_is_independent(self) -> None:
259 cr1 = ConflictRecord(path="a.py")
260 cr2 = ConflictRecord(path="b.py")
261 cr1.addresses.append("a.py::f")
262 assert cr2.addresses == []
263
264 def test_field_names(self) -> None:
265 field_names = {f.name for f in fields(ConflictRecord)}
266 assert "path" in field_names
267 assert "conflict_type" in field_names
268 assert "ours_summary" in field_names
269 assert "theirs_summary" in field_names
270 assert "addresses" in field_names
271
272
273 # ---------------------------------------------------------------------------
274 # SemVerBump — valid literals
275 # ---------------------------------------------------------------------------
276
277
278 class TestSemVerBumpLiterals:
279 def test_all_values_are_valid_strings(self) -> None:
280 # SemVerBump is a Literal type alias; verify all four values are strings.
281 valid: tuple[str, ...] = ("major", "minor", "patch", "none")
282 for val in valid:
283 assert isinstance(val, str)
284
285 def test_infer_returns_semverbump_type(self) -> None:
286 bump, _ = infer_sem_ver_bump(_delta())
287 assert bump in ("major", "minor", "patch", "none")