gabriel / muse public
test_cache_base.py python
286 lines 10.2 KB
Raw
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