test_cache_base.py
python
sha256:f6cd81bc71702f5c1c6890bd39aaba994fe58c75f019d7c03934724fa2739bb4
fix: carry dev changes harmony dropped in merge — detached …
Sonnet 4.6
minor
⚠ breaking
16 days ago
| 1 | """Phase 6 — Tests for MsgpackCache ABC (muse/core/cache_base.py). |
| 2 | |
| 3 | A minimal concrete subclass (_TestCache) is defined here to exercise every |
| 4 | shared behaviour: load / save / dirty tracking / prune / size / empty. |
| 5 | |
| 6 | Existing per-cache test files (symbol, callgraph, implicit_edge, invariants) |
| 7 | are the regression gate — they must continue to pass unmodified after the four |
| 8 | production caches inherit from MsgpackCache. |
| 9 | """ |
| 10 | |
| 11 | from __future__ import annotations |
| 12 | |
| 13 | import pathlib |
| 14 | |
| 15 | import pytest |
| 16 | |
| 17 | from muse.core.cache_base import MsgpackCache, _RawCacheMap |
| 18 | from muse.core.paths import muse_dir |
| 19 | |
| 20 | |
| 21 | # --------------------------------------------------------------------------- |
| 22 | # Minimal concrete subclass used by all tests |
| 23 | # --------------------------------------------------------------------------- |
| 24 | |
| 25 | |
| 26 | class _TestCache(MsgpackCache): |
| 27 | """Trivial string→string cache for testing the shared base-class logic.""" |
| 28 | |
| 29 | _CACHE_FILENAME = "test.json" |
| 30 | _CACHE_VERSION = 1 |
| 31 | _TEMP_PREFIX = ".test_cache_" |
| 32 | |
| 33 | @classmethod |
| 34 | def _deserialize_entries(cls, raw: _RawCacheMap) -> _RawCacheMap: |
| 35 | return {k: v for k, v in raw.items() |
| 36 | if isinstance(k, str) and isinstance(v, str)} |
| 37 | |
| 38 | def _serialize_entries(self) -> _RawCacheMap: |
| 39 | return dict(self._entries) |
| 40 | |
| 41 | |
| 42 | # --------------------------------------------------------------------------- |
| 43 | # Fixture helper |
| 44 | # --------------------------------------------------------------------------- |
| 45 | |
| 46 | |
| 47 | def _make_muse_dir(tmp_path: pathlib.Path) -> pathlib.Path: |
| 48 | """Create a .muse/cache/ tree and return the .muse path.""" |
| 49 | dot_muse = muse_dir(tmp_path) |
| 50 | (dot_muse / "cache").mkdir(parents=True) |
| 51 | return dot_muse |
| 52 | |
| 53 | |
| 54 | # --------------------------------------------------------------------------- |
| 55 | # Tier 1 — Unit (no filesystem I/O) |
| 56 | # --------------------------------------------------------------------------- |
| 57 | |
| 58 | |
| 59 | class TestUnit: |
| 60 | def test_get_miss_returns_none(self) -> None: |
| 61 | cache = _TestCache(None, {}) |
| 62 | assert cache.get("missing") is None |
| 63 | |
| 64 | def test_put_sets_dirty(self) -> None: |
| 65 | cache = _TestCache(None, {}) |
| 66 | cache.put("k", "v") |
| 67 | assert cache._dirty |
| 68 | |
| 69 | def test_get_hit_after_put(self) -> None: |
| 70 | cache = _TestCache(None, {}) |
| 71 | cache.put("k", "v") |
| 72 | assert cache.get("k") == "v" |
| 73 | |
| 74 | def test_prune_removes_stale_and_sets_dirty(self) -> None: |
| 75 | cache = _TestCache(None, {"a": "1", "b": "2", "c": "3"}) |
| 76 | cache.prune({"a"}) |
| 77 | assert cache.get("a") == "1" |
| 78 | assert cache.get("b") is None |
| 79 | assert cache.get("c") is None |
| 80 | assert cache._dirty |
| 81 | |
| 82 | def test_prune_noop_when_all_live(self) -> None: |
| 83 | cache = _TestCache(None, {"a": "1"}) |
| 84 | cache.prune({"a", "b"}) |
| 85 | assert not cache._dirty |
| 86 | |
| 87 | def test_size_property(self) -> None: |
| 88 | cache = _TestCache(None, {"a": "1", "b": "2"}) |
| 89 | assert cache.size == 2 |
| 90 | |
| 91 | def test_empty_cache_dir_is_none(self) -> None: |
| 92 | cache = _TestCache.empty() |
| 93 | assert cache._cache_dir is None |
| 94 | assert cache.size == 0 |
| 95 | |
| 96 | def test_save_on_empty_is_noop_no_file(self, tmp_path: pathlib.Path) -> None: |
| 97 | cache = _TestCache.empty() |
| 98 | cache.put("k", "v") # dirty=True but _cache_dir is None |
| 99 | cache.save() |
| 100 | assert not any(tmp_path.rglob("*.json")) |
| 101 | |
| 102 | |
| 103 | # --------------------------------------------------------------------------- |
| 104 | # Tier 2 — Integration (real filesystem, no subprocess) |
| 105 | # --------------------------------------------------------------------------- |
| 106 | |
| 107 | |
| 108 | class TestIntegration: |
| 109 | def test_load_missing_file_returns_empty(self, tmp_path: pathlib.Path) -> None: |
| 110 | muse = _make_muse_dir(tmp_path) |
| 111 | cache = _TestCache.load(muse) |
| 112 | assert cache.size == 0 |
| 113 | assert cache._cache_dir == muse / "cache" |
| 114 | |
| 115 | def test_save_creates_file_at_correct_path(self, tmp_path: pathlib.Path) -> None: |
| 116 | muse = _make_muse_dir(tmp_path) |
| 117 | cache = _TestCache.load(muse) |
| 118 | cache.put("k", "v") |
| 119 | cache.save() |
| 120 | assert (muse / "cache" / "test.json").is_file() |
| 121 | |
| 122 | def test_round_trip_data_intact(self, tmp_path: pathlib.Path) -> None: |
| 123 | muse = _make_muse_dir(tmp_path) |
| 124 | cache = _TestCache.load(muse) |
| 125 | cache.put("key1", "val1") |
| 126 | cache.put("key2", "val2") |
| 127 | cache.save() |
| 128 | loaded = _TestCache.load(muse) |
| 129 | assert loaded.get("key1") == "val1" |
| 130 | assert loaded.get("key2") == "val2" |
| 131 | assert loaded.size == 2 |
| 132 | |
| 133 | def test_save_noop_when_not_dirty_mtime_unchanged(self, tmp_path: pathlib.Path) -> None: |
| 134 | muse = _make_muse_dir(tmp_path) |
| 135 | cache = _TestCache.load(muse) |
| 136 | cache.put("k", "v") |
| 137 | cache.save() |
| 138 | cache_file = muse / "cache" / "test.json" |
| 139 | mtime1 = cache_file.stat().st_mtime_ns |
| 140 | cache2 = _TestCache.load(muse) |
| 141 | cache2.save() |
| 142 | mtime2 = cache_file.stat().st_mtime_ns |
| 143 | assert mtime1 == mtime2 |
| 144 | |
| 145 | def test_dirty_false_after_successful_save(self, tmp_path: pathlib.Path) -> None: |
| 146 | muse = _make_muse_dir(tmp_path) |
| 147 | cache = _TestCache.load(muse) |
| 148 | cache.put("k", "v") |
| 149 | cache.save() |
| 150 | assert not cache._dirty |
| 151 | |
| 152 | |
| 153 | # --------------------------------------------------------------------------- |
| 154 | # Tier 5 — Data integrity |
| 155 | # --------------------------------------------------------------------------- |
| 156 | |
| 157 | |
| 158 | class TestDataIntegrity: |
| 159 | def test_corrupt_bytes_returns_empty(self, tmp_path: pathlib.Path) -> None: |
| 160 | muse = _make_muse_dir(tmp_path) |
| 161 | (muse / "cache" / "test.json").write_bytes(b"not valid JSON !!!") |
| 162 | cache = _TestCache.load(muse) |
| 163 | assert cache.size == 0 |
| 164 | |
| 165 | def test_wrong_version_returns_empty(self, tmp_path: pathlib.Path) -> None: |
| 166 | import json as _json |
| 167 | muse = _make_muse_dir(tmp_path) |
| 168 | payload = _json.dumps({"version": 99, "entries": {"k": "v"}}).encode() |
| 169 | (muse / "cache" / "test.json").write_bytes(payload) |
| 170 | cache = _TestCache.load(muse) |
| 171 | assert cache.size == 0 |
| 172 | |
| 173 | def test_missing_entries_key_returns_empty(self, tmp_path: pathlib.Path) -> None: |
| 174 | import json as _json |
| 175 | muse = _make_muse_dir(tmp_path) |
| 176 | payload = _json.dumps({"version": 1}).encode() |
| 177 | (muse / "cache" / "test.json").write_bytes(payload) |
| 178 | cache = _TestCache.load(muse) |
| 179 | assert cache.size == 0 |
| 180 | |
| 181 | def test_invalid_entry_skipped_valid_survives(self, tmp_path: pathlib.Path) -> None: |
| 182 | import json as _json |
| 183 | muse = _make_muse_dir(tmp_path) |
| 184 | # "bad" has int value → _deserialize_entries skips it; "good" survives |
| 185 | payload = _json.dumps( |
| 186 | {"version": 1, "entries": {"bad": 123, "good": "value"}} |
| 187 | ).encode() |
| 188 | (muse / "cache" / "test.json").write_bytes(payload) |
| 189 | cache = _TestCache.load(muse) |
| 190 | assert cache.get("good") == "value" |
| 191 | assert cache.get("bad") is None |
| 192 | |
| 193 | def test_no_tmp_leftover_after_save(self, tmp_path: pathlib.Path) -> None: |
| 194 | muse = _make_muse_dir(tmp_path) |
| 195 | cache = _TestCache.load(muse) |
| 196 | cache.put("k", "v") |
| 197 | cache.save() |
| 198 | assert not any((muse / "cache").glob("*.tmp")) |
| 199 | |
| 200 | |
| 201 | # --------------------------------------------------------------------------- |
| 202 | # from_root — integration tests |
| 203 | # --------------------------------------------------------------------------- |
| 204 | |
| 205 | |
| 206 | class TestFromRoot: |
| 207 | """from_root(repo_root) — the canonical entry point for CLI callers.""" |
| 208 | |
| 209 | def test_from_root_with_muse_dir_sets_cache_dir(self, tmp_path: pathlib.Path) -> None: |
| 210 | muse = _make_muse_dir(tmp_path) # creates tmp_path/.muse/cache/ |
| 211 | cache = _TestCache.from_root(tmp_path) |
| 212 | assert cache._cache_dir == muse / "cache" |
| 213 | |
| 214 | def test_from_root_without_muse_dir_returns_empty(self, tmp_path: pathlib.Path) -> None: |
| 215 | # No .muse/ directory — should get a no-op empty cache |
| 216 | cache = _TestCache.from_root(tmp_path) |
| 217 | assert cache._cache_dir is None |
| 218 | assert cache.size == 0 |
| 219 | |
| 220 | def test_from_root_round_trip(self, tmp_path: pathlib.Path) -> None: |
| 221 | _make_muse_dir(tmp_path) |
| 222 | cache = _TestCache.from_root(tmp_path) |
| 223 | cache.put("k", "v") |
| 224 | cache.save() |
| 225 | reloaded = _TestCache.from_root(tmp_path) |
| 226 | assert reloaded.get("k") == "v" |
| 227 | |
| 228 | def test_from_root_empty_save_is_noop(self, tmp_path: pathlib.Path) -> None: |
| 229 | # No .muse/ → empty cache; save must not create any files |
| 230 | cache = _TestCache.from_root(tmp_path) |
| 231 | cache.put("k", "v") |
| 232 | cache.save() |
| 233 | assert not any(tmp_path.rglob("*.json")) |
| 234 | |
| 235 | |
| 236 | # --------------------------------------------------------------------------- |
| 237 | # Tier 6 — Performance |
| 238 | # --------------------------------------------------------------------------- |
| 239 | |
| 240 | |
| 241 | class TestPerformance: |
| 242 | def test_not_dirty_save_under_1ms(self, tmp_path: pathlib.Path) -> None: |
| 243 | import time |
| 244 | |
| 245 | muse = _make_muse_dir(tmp_path) |
| 246 | cache = _TestCache.load(muse) |
| 247 | cache.put("k", "v") |
| 248 | cache.save() |
| 249 | cache2 = _TestCache.load(muse) |
| 250 | t0 = time.monotonic() |
| 251 | cache2.save() |
| 252 | elapsed_ms = (time.monotonic() - t0) * 1000 |
| 253 | assert elapsed_ms < 1.0 |
| 254 | |
| 255 | |
| 256 | # --------------------------------------------------------------------------- |
| 257 | # Tier 7 — Security |
| 258 | # --------------------------------------------------------------------------- |
| 259 | |
| 260 | |
| 261 | class TestSecurity: |
| 262 | def test_mode_000_file_returns_empty(self, tmp_path: pathlib.Path) -> None: |
| 263 | muse = _make_muse_dir(tmp_path) |
| 264 | cache = _TestCache.load(muse) |
| 265 | cache.put("k", "v") |
| 266 | cache.save() |
| 267 | cache_file = muse / "cache" / "test.json" |
| 268 | cache_file.chmod(0o000) |
| 269 | try: |
| 270 | loaded = _TestCache.load(muse) |
| 271 | assert loaded.size == 0 |
| 272 | finally: |
| 273 | cache_file.chmod(0o644) |
| 274 | |
| 275 | def test_save_overwrites_symlink_not_target(self, tmp_path: pathlib.Path) -> None: |
| 276 | muse = _make_muse_dir(tmp_path) |
| 277 | target = tmp_path / "other_file" |
| 278 | target.write_bytes(b"original") |
| 279 | symlink = muse / "cache" / "test.json" |
| 280 | symlink.symlink_to(target) |
| 281 | cache = _TestCache(muse / "cache", {"k": "v"}) |
| 282 | cache._dirty = True |
| 283 | cache.save() |
| 284 | # symlink is replaced by a real file (os.replace removes the symlink) |
| 285 | assert not symlink.is_symlink() |
| 286 | # original target is untouched |
| 287 | assert target.read_bytes() == b"original" |
File History
2 commits
sha256:43c82f6d4fa2e85dd9ed9dd1a31199ec6b481191517aba66dfa9da275dbfa1af
Merge branch 'dev' into main
Human
1 day ago
sha256:fb67fed5a4d3e40de84bdd163de94ef1386570bef1dd1a020a732c8a038962ce
Merge branch 'dev' into main
Human
20 days ago