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