gabriel / muse public
test_cache_base.py python
287 lines 10.2 KB
Raw
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:fb67fed5a4d3e40de84bdd163de94ef1386570bef1dd1a020a732c8a038962ce Merge branch 'dev' into main Human 20 days ago