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