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 | |
| 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") |