test_resolve_phase1.py
python
sha256:84df9126d09aeec0b8f1b908f0b06c10913feec28f3514b382efb1ba6d619385
refactor: rename StructuredMergePlugin to AddressedMergePlu…
Sonnet 4.6
minor
⚠ breaking
23 days ago
| 1 | """Tests for Phase 1 of issue #8: resolve_path and resolve_symbol primitives. |
| 2 | |
| 3 | Coverage |
| 4 | -------- |
| 5 | - resolve_path: removes all conflict entries whose file portion matches path |
| 6 | - resolve_path: leaves original_conflict_paths untouched |
| 7 | - resolve_path: idempotent when path already resolved |
| 8 | - resolve_path: returns empty list when path not in conflict_paths |
| 9 | - resolve_path: raises ValueError when no merge in progress |
| 10 | - resolve_symbol: removes exactly one matching entry |
| 11 | - resolve_symbol: returns False when symbol not present (idempotent) |
| 12 | - resolve_symbol: leaves original_conflict_paths untouched |
| 13 | - resolve_symbol: raises ValueError when no merge in progress |
| 14 | - Mixed symbol/file entries: resolve_path clears only matching file portion |
| 15 | - After resolve_path clears all conflicts, conflict_paths is empty |
| 16 | - write_merge_state / read_merge_state round-trip preserves all fields |
| 17 | """ |
| 18 | |
| 19 | from __future__ import annotations |
| 20 | |
| 21 | import json |
| 22 | import pathlib |
| 23 | |
| 24 | import pytest |
| 25 | |
| 26 | from muse.core.merge_engine import ( |
| 27 | MergeState, |
| 28 | clear_merge_state, |
| 29 | read_merge_state, |
| 30 | resolve_path, |
| 31 | resolve_symbol, |
| 32 | write_merge_state, |
| 33 | ) |
| 34 | from muse.core.types import MUSE_DIR, fake_id |
| 35 | |
| 36 | |
| 37 | # --------------------------------------------------------------------------- |
| 38 | # Helpers |
| 39 | # --------------------------------------------------------------------------- |
| 40 | |
| 41 | _BASE = fake_id("base") |
| 42 | _OURS = fake_id("ours") |
| 43 | _THEIRS = fake_id("theirs") |
| 44 | |
| 45 | |
| 46 | def _repo(tmp_path: pathlib.Path) -> pathlib.Path: |
| 47 | (tmp_path / MUSE_DIR).mkdir() |
| 48 | return tmp_path |
| 49 | |
| 50 | |
| 51 | def _write(root: pathlib.Path, conflicts: list[str]) -> None: |
| 52 | write_merge_state( |
| 53 | root, |
| 54 | base_commit=_BASE, |
| 55 | ours_commit=_OURS, |
| 56 | theirs_commit=_THEIRS, |
| 57 | conflict_paths=conflicts, |
| 58 | other_branch="feat/x", |
| 59 | ) |
| 60 | |
| 61 | |
| 62 | def _state(root: pathlib.Path) -> MergeState: |
| 63 | s = read_merge_state(root) |
| 64 | assert s is not None |
| 65 | return s |
| 66 | |
| 67 | |
| 68 | # --------------------------------------------------------------------------- |
| 69 | # resolve_path |
| 70 | # --------------------------------------------------------------------------- |
| 71 | |
| 72 | class TestResolvePath: |
| 73 | def test_clears_plain_file_entry(self, tmp_path: pathlib.Path) -> None: |
| 74 | repo = _repo(tmp_path) |
| 75 | _write(repo, ["hello.md", "world.md"]) |
| 76 | cleared = resolve_path(repo, "hello.md") |
| 77 | assert cleared == ["hello.md"] |
| 78 | assert _state(repo).conflict_paths == ["world.md"] |
| 79 | |
| 80 | def test_clears_symbol_level_entries_for_file(self, tmp_path: pathlib.Path) -> None: |
| 81 | repo = _repo(tmp_path) |
| 82 | _write(repo, ["hello.md::Hello World", "hello.md::Subtitle", "other.py"]) |
| 83 | cleared = resolve_path(repo, "hello.md") |
| 84 | assert sorted(cleared) == ["hello.md::Hello World", "hello.md::Subtitle"] |
| 85 | assert _state(repo).conflict_paths == ["other.py"] |
| 86 | |
| 87 | def test_clears_mixed_plain_and_symbol_entries(self, tmp_path: pathlib.Path) -> None: |
| 88 | repo = _repo(tmp_path) |
| 89 | _write(repo, ["hello.md", "hello.md::Section", "readme.txt"]) |
| 90 | cleared = resolve_path(repo, "hello.md") |
| 91 | assert sorted(cleared) == ["hello.md", "hello.md::Section"] |
| 92 | assert _state(repo).conflict_paths == ["readme.txt"] |
| 93 | |
| 94 | def test_returns_empty_when_path_not_conflicted(self, tmp_path: pathlib.Path) -> None: |
| 95 | repo = _repo(tmp_path) |
| 96 | _write(repo, ["other.py"]) |
| 97 | cleared = resolve_path(repo, "hello.md") |
| 98 | assert cleared == [] |
| 99 | assert _state(repo).conflict_paths == ["other.py"] |
| 100 | |
| 101 | def test_idempotent_second_call_returns_empty(self, tmp_path: pathlib.Path) -> None: |
| 102 | repo = _repo(tmp_path) |
| 103 | _write(repo, ["hello.md"]) |
| 104 | resolve_path(repo, "hello.md") |
| 105 | cleared2 = resolve_path(repo, "hello.md") |
| 106 | assert cleared2 == [] |
| 107 | |
| 108 | def test_does_not_mutate_original_conflict_paths(self, tmp_path: pathlib.Path) -> None: |
| 109 | repo = _repo(tmp_path) |
| 110 | _write(repo, ["hello.md", "world.md"]) |
| 111 | original_before = _state(repo).original_conflict_paths[:] |
| 112 | resolve_path(repo, "hello.md") |
| 113 | assert _state(repo).original_conflict_paths == original_before |
| 114 | |
| 115 | def test_empties_conflict_paths_when_all_resolved(self, tmp_path: pathlib.Path) -> None: |
| 116 | repo = _repo(tmp_path) |
| 117 | _write(repo, ["hello.md::A", "hello.md::B"]) |
| 118 | resolve_path(repo, "hello.md") |
| 119 | assert _state(repo).conflict_paths == [] |
| 120 | |
| 121 | def test_raises_when_no_merge_in_progress(self, tmp_path: pathlib.Path) -> None: |
| 122 | repo = _repo(tmp_path) |
| 123 | with pytest.raises(ValueError, match="No merge in progress"): |
| 124 | resolve_path(repo, "hello.md") |
| 125 | |
| 126 | def test_preserves_other_merge_state_fields(self, tmp_path: pathlib.Path) -> None: |
| 127 | repo = _repo(tmp_path) |
| 128 | _write(repo, ["hello.md", "other.py"]) |
| 129 | resolve_path(repo, "hello.md") |
| 130 | s = _state(repo) |
| 131 | assert s.base_commit == _BASE |
| 132 | assert s.ours_commit == _OURS |
| 133 | assert s.theirs_commit == _THEIRS |
| 134 | assert s.other_branch == "feat/x" |
| 135 | |
| 136 | def test_does_not_clear_entries_with_similar_prefix(self, tmp_path: pathlib.Path) -> None: |
| 137 | """resolve_path("hello.md") must not clear "hello.md.bak" or "hello.md2".""" |
| 138 | repo = _repo(tmp_path) |
| 139 | _write(repo, ["hello.md", "hello.md.bak", "hello.md2"]) |
| 140 | cleared = resolve_path(repo, "hello.md") |
| 141 | assert cleared == ["hello.md"] |
| 142 | remaining = _state(repo).conflict_paths |
| 143 | assert "hello.md.bak" in remaining |
| 144 | assert "hello.md2" in remaining |
| 145 | |
| 146 | |
| 147 | # --------------------------------------------------------------------------- |
| 148 | # resolve_symbol |
| 149 | # --------------------------------------------------------------------------- |
| 150 | |
| 151 | class TestResolveSymbol: |
| 152 | def test_removes_plain_path_entry(self, tmp_path: pathlib.Path) -> None: |
| 153 | repo = _repo(tmp_path) |
| 154 | _write(repo, ["hello.md", "world.md"]) |
| 155 | found = resolve_symbol(repo, "hello.md") |
| 156 | assert found is True |
| 157 | assert _state(repo).conflict_paths == ["world.md"] |
| 158 | |
| 159 | def test_removes_symbol_address_entry(self, tmp_path: pathlib.Path) -> None: |
| 160 | repo = _repo(tmp_path) |
| 161 | _write(repo, ["hello.md::Hello World", "hello.md::Subtitle"]) |
| 162 | found = resolve_symbol(repo, "hello.md::Hello World") |
| 163 | assert found is True |
| 164 | assert _state(repo).conflict_paths == ["hello.md::Subtitle"] |
| 165 | |
| 166 | def test_returns_false_when_not_present(self, tmp_path: pathlib.Path) -> None: |
| 167 | repo = _repo(tmp_path) |
| 168 | _write(repo, ["other.py"]) |
| 169 | found = resolve_symbol(repo, "hello.md::Missing") |
| 170 | assert found is False |
| 171 | assert _state(repo).conflict_paths == ["other.py"] |
| 172 | |
| 173 | def test_idempotent_second_call_returns_false(self, tmp_path: pathlib.Path) -> None: |
| 174 | repo = _repo(tmp_path) |
| 175 | _write(repo, ["hello.md"]) |
| 176 | resolve_symbol(repo, "hello.md") |
| 177 | found2 = resolve_symbol(repo, "hello.md") |
| 178 | assert found2 is False |
| 179 | |
| 180 | def test_does_not_mutate_original_conflict_paths(self, tmp_path: pathlib.Path) -> None: |
| 181 | repo = _repo(tmp_path) |
| 182 | _write(repo, ["hello.md", "world.md"]) |
| 183 | original_before = _state(repo).original_conflict_paths[:] |
| 184 | resolve_symbol(repo, "hello.md") |
| 185 | assert _state(repo).original_conflict_paths == original_before |
| 186 | |
| 187 | def test_raises_when_no_merge_in_progress(self, tmp_path: pathlib.Path) -> None: |
| 188 | repo = _repo(tmp_path) |
| 189 | with pytest.raises(ValueError, match="No merge in progress"): |
| 190 | resolve_symbol(repo, "hello.md") |
| 191 | |
| 192 | def test_removes_only_exact_match(self, tmp_path: pathlib.Path) -> None: |
| 193 | """resolve_symbol must not remove "hello.md::A" when asked for "hello.md".""" |
| 194 | repo = _repo(tmp_path) |
| 195 | _write(repo, ["hello.md", "hello.md::A"]) |
| 196 | resolve_symbol(repo, "hello.md") |
| 197 | assert _state(repo).conflict_paths == ["hello.md::A"] |
| 198 | |
| 199 | def test_preserves_other_merge_state_fields(self, tmp_path: pathlib.Path) -> None: |
| 200 | repo = _repo(tmp_path) |
| 201 | _write(repo, ["hello.md"]) |
| 202 | resolve_symbol(repo, "hello.md") |
| 203 | s = _state(repo) |
| 204 | assert s.base_commit == _BASE |
| 205 | assert s.ours_commit == _OURS |
| 206 | assert s.theirs_commit == _THEIRS |
| 207 | assert s.other_branch == "feat/x" |
| 208 | |
| 209 | def test_empties_conflict_paths_when_last_entry_resolved(self, tmp_path: pathlib.Path) -> None: |
| 210 | repo = _repo(tmp_path) |
| 211 | _write(repo, ["only.py"]) |
| 212 | resolve_symbol(repo, "only.py") |
| 213 | assert _state(repo).conflict_paths == [] |
| 214 | |
| 215 | def test_original_paths_persisted_across_multiple_resolves(self, tmp_path: pathlib.Path) -> None: |
| 216 | repo = _repo(tmp_path) |
| 217 | _write(repo, ["a.py", "b.py", "c.py"]) |
| 218 | original = _state(repo).original_conflict_paths[:] |
| 219 | resolve_symbol(repo, "a.py") |
| 220 | resolve_symbol(repo, "b.py") |
| 221 | resolve_symbol(repo, "c.py") |
| 222 | assert _state(repo).original_conflict_paths == original |
| 223 | assert _state(repo).conflict_paths == [] |
File History
1 commit
sha256:84df9126d09aeec0b8f1b908f0b06c10913feec28f3514b382efb1ba6d619385
refactor: rename StructuredMergePlugin to AddressedMergePlu…
Sonnet 4.6
minor
⚠
23 days ago