Coverage for agentos/tests/test_cache.py: 0%
361 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-09 07:12 +0800
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-09 07:12 +0800
1"""Tests for agentos.core.cache — MemoryCacheBackend, Cache, TieredCache, Serializers."""
3import asyncio
5import pytest
7from agentos.core.cache import (
8 Cache,
9 CacheConfig,
10 CacheStats,
11 JSONSerializer,
12 MemoryCacheBackend,
13 PickleSerializer,
14 RedisCacheBackend,
15 TieredCache,
16 _build_signature,
17 cached,
18)
20# ============================================================================
21# CacheStats
22# ============================================================================
24class TestCacheStats:
25 def test_hit_rate_empty(self):
26 s = CacheStats()
27 assert s.hit_rate == 0.0
29 def test_hit_rate_half(self):
30 s = CacheStats(hits=5, misses=5)
31 assert s.hit_rate == 0.5
33 def test_hit_rate_all_hits(self):
34 s = CacheStats(hits=10)
35 assert s.hit_rate == 1.0
37 def test_snapshot(self):
38 s = CacheStats(hits=3, misses=2, errors=1)
39 snap = s.snapshot()
40 assert snap == {"hits": 3, "misses": 2, "sets": 0, "deletes": 0, "evictions": 0, "errors": 1}
43# ============================================================================
44# Serializers
45# ============================================================================
47class TestPickleSerializer:
48 def test_roundtrip(self):
49 s = PickleSerializer()
50 data = {"hello": [1, 2, 3], "nested": {"a": True}}
51 raw = s.dumps(data)
52 assert s.loads(raw) == data
54 def test_primitives(self):
55 s = PickleSerializer()
56 assert s.loads(s.dumps(42)) == 42
57 assert s.loads(s.dumps("hello")) == "hello"
60class TestJSONSerializer:
61 def test_roundtrip(self):
62 s = JSONSerializer()
63 data = {"hello": "world", "n": 123}
64 raw = s.dumps(data)
65 assert s.loads(raw) == data
67 def test_default_str(self):
68 s = JSONSerializer()
69 # bytes aren't JSON-serializable; default=str uses repr
70 raw = s.dumps({"key": b"value"})
71 result = s.loads(raw)
72 assert isinstance(result["key"], str)
75# ============================================================================
76# MemoryCacheBackend
77# ============================================================================
79class TestMemoryCacheBackend:
80 @pytest.mark.asyncio
81 async def test_set_get(self):
82 b = MemoryCacheBackend()
83 await b.set("k", b"v")
84 assert await b.get("k") == b"v"
86 @pytest.mark.asyncio
87 async def test_get_missing(self):
88 b = MemoryCacheBackend()
89 assert await b.get("missing") is None
91 @pytest.mark.asyncio
92 async def test_ttl_expiry(self):
93 b = MemoryCacheBackend(default_ttl=0.001)
94 await b.set("k", b"v")
95 await asyncio.sleep(0.01)
96 assert await b.get("k") is None
98 @pytest.mark.asyncio
99 async def test_custom_ttl(self):
100 b = MemoryCacheBackend(default_ttl=60)
101 await b.set("k", b"v", ttl=0.001)
102 await asyncio.sleep(0.01)
103 assert await b.get("k") is None
105 @pytest.mark.asyncio
106 async def test_no_ttl(self):
107 b = MemoryCacheBackend(default_ttl=None)
108 await b.set("k", b"v")
109 await asyncio.sleep(0.01)
110 assert await b.get("k") == b"v"
112 @pytest.mark.asyncio
113 async def test_delete(self):
114 b = MemoryCacheBackend()
115 await b.set("k", b"v")
116 assert await b.delete("k") is True
117 assert await b.get("k") is None
119 @pytest.mark.asyncio
120 async def test_delete_missing(self):
121 b = MemoryCacheBackend()
122 assert await b.delete("missing") is False
124 @pytest.mark.asyncio
125 async def test_exists(self):
126 b = MemoryCacheBackend()
127 await b.set("k", b"v")
128 assert await b.exists("k") is True
129 assert await b.exists("missing") is False
131 @pytest.mark.asyncio
132 async def test_clear(self):
133 b = MemoryCacheBackend()
134 await b.set("a", b"1")
135 await b.set("b", b"2")
136 await b.clear()
137 assert await b.get("a") is None
138 assert await b.get("b") is None
140 @pytest.mark.asyncio
141 async def test_lru_eviction(self):
142 b = MemoryCacheBackend(max_size=2)
143 await b.set("a", b"1")
144 await b.set("b", b"2")
145 await b.set("c", b"3")
146 assert await b.get("a") is None # evicted
147 assert await b.get("b") == b"2"
148 assert await b.get("c") == b"3"
150 @pytest.mark.asyncio
151 async def test_lru_promotion(self):
152 b = MemoryCacheBackend(max_size=2)
153 await b.set("a", b"1")
154 await b.set("b", b"2")
155 await b.get("a") # promote a
156 await b.set("c", b"3")
157 assert await b.get("a") == b"1" # not evicted
158 assert await b.get("b") is None # evicted
160 @pytest.mark.asyncio
161 async def test_size(self):
162 b = MemoryCacheBackend(max_size=100)
163 await b.set("a", b"1")
164 await b.set("b", b"2")
165 assert b.size == 2
167 @pytest.mark.asyncio
168 async def test_get_many(self):
169 b = MemoryCacheBackend()
170 await b.set("a", b"1")
171 await b.set("b", b"2")
172 result = await b.get_many(["a", "b", "c"])
173 assert result == {"a": b"1", "b": b"2"}
175 @pytest.mark.asyncio
176 async def test_set_many(self):
177 b = MemoryCacheBackend()
178 await b.set_many({"a": b"1", "b": b"2"})
179 assert await b.get("a") == b"1"
180 assert await b.get("b") == b"2"
182 @pytest.mark.asyncio
183 async def test_delete_many(self):
184 b = MemoryCacheBackend()
185 await b.set("a", b"1")
186 await b.set("b", b"2")
187 count = await b.delete_many(["a", "b", "c"])
188 assert count == 2
189 assert await b.get("a") is None
192# ============================================================================
193# Cache (high-level API)
194# ============================================================================
196class TestCache:
197 @pytest.mark.asyncio
198 async def test_set_get(self):
199 c = Cache[str](MemoryCacheBackend())
200 await c.set("k", "hello")
201 assert await c.get("k") == "hello"
203 @pytest.mark.asyncio
204 async def test_get_missing(self):
205 c = Cache[int](MemoryCacheBackend())
206 assert await c.get("k") is None
208 @pytest.mark.asyncio
209 async def test_exists(self):
210 c = Cache[str](MemoryCacheBackend())
211 await c.set("k", "v")
212 assert await c.exists("k")
213 assert not await c.exists("missing")
215 @pytest.mark.asyncio
216 async def test_delete(self):
217 c = Cache[str](MemoryCacheBackend())
218 await c.set("k", "v")
219 assert await c.delete("k")
220 assert await c.get("k") is None
222 @pytest.mark.asyncio
223 async def test_clear(self):
224 c = Cache[str](MemoryCacheBackend())
225 await c.set("a", "1")
226 await c.set("b", "2")
227 await c.clear()
228 assert await c.get("a") is None
230 @pytest.mark.asyncio
231 async def test_get_or_default(self):
232 c = Cache[str](MemoryCacheBackend())
233 assert await c.get_or_default("k", "default") == "default"
234 await c.set("k", "real")
235 assert await c.get_or_default("k", "default") == "real"
237 @pytest.mark.asyncio
238 async def test_get_or_set(self):
239 c = Cache[int](MemoryCacheBackend())
240 call_count = 0
242 def factory():
243 nonlocal call_count
244 call_count += 1
245 return 42
247 v1 = await c.get_or_set("k", factory)
248 v2 = await c.get_or_set("k", factory)
249 assert v1 == 42
250 assert v2 == 42
251 assert call_count == 1 # factory called once
253 @pytest.mark.asyncio
254 async def test_get_or_set_force_refresh(self):
255 c = Cache[int](MemoryCacheBackend())
256 call_count = 0
258 def factory():
259 nonlocal call_count
260 call_count += 1
261 return call_count
263 await c.set("k", 0)
264 v = await c.get_or_set("k", factory, force_refresh=True)
265 assert v == 1
267 @pytest.mark.asyncio
268 async def test_get_or_set_async_factory(self):
269 c = Cache[int](MemoryCacheBackend())
271 async def factory():
272 return 99
274 v = await c.get_or_set("k", factory)
275 assert v == 99
277 @pytest.mark.asyncio
278 async def test_stats(self):
279 c = Cache[str](MemoryCacheBackend())
280 await c.set("a", "1")
281 await c.get("a")
282 await c.get("missing")
283 assert c.stats.hits == 1
284 assert c.stats.misses == 1
285 assert c.stats.sets == 1
287 @pytest.mark.asyncio
288 async def test_key_prefix(self):
289 config = CacheConfig(key_prefix="pfx:")
290 c = Cache[str](MemoryCacheBackend(), config)
291 await c.set("k", "v")
292 assert await c.get("k") == "v"
294 @pytest.mark.asyncio
295 async def test_hash_keys(self):
296 config = CacheConfig(hash_keys=True)
297 c = Cache[str](MemoryCacheBackend(), config)
298 await c.set("very_long_key" * 10, "v")
299 assert await c.get("very_long_key" * 10) == "v"
301 @pytest.mark.asyncio
302 async def test_get_many(self):
303 c = Cache[str](MemoryCacheBackend())
304 await c.set("a", "1")
305 await c.set("b", "2")
306 result = await c.get_many(["a", "b", "c"])
307 assert result == {"a": "1", "b": "2", "c": None}
309 @pytest.mark.asyncio
310 async def test_set_many(self):
311 c = Cache[str](MemoryCacheBackend())
312 await c.set_many({"a": "1", "b": "2"})
313 assert await c.get("a") == "1"
314 assert await c.get("b") == "2"
316 @pytest.mark.asyncio
317 async def test_delete_many(self):
318 c = Cache[str](MemoryCacheBackend())
319 await c.set("a", "1")
320 await c.set("b", "2")
321 count = await c.delete_many(["a", "b", "c"])
322 assert count == 2
324 @pytest.mark.asyncio
325 async def test_json_serializer(self):
326 config = CacheConfig(serializer=JSONSerializer())
327 c = Cache[dict](MemoryCacheBackend(), config)
328 await c.set("k", {"a": 1})
329 assert await c.get("k") == {"a": 1}
331 @pytest.mark.asyncio
332 async def test_pickle_complex(self):
333 c = Cache[dict](MemoryCacheBackend())
334 await c.set("k", {"nested": {1, 2, 3}})
335 assert await c.get("k") == {"nested": {1, 2, 3}}
338# ============================================================================
339# TieredCache
340# ============================================================================
342class TestTieredCache:
343 @pytest.mark.asyncio
344 async def test_get_l1_hit(self):
345 l1 = Cache[int](MemoryCacheBackend())
346 l2 = Cache[int](MemoryCacheBackend())
347 tc = TieredCache(l1, l2)
348 await l1.set("k", 1)
349 assert await tc.get("k") == 1
351 @pytest.mark.asyncio
352 async def test_get_l2_fallback_and_promote(self):
353 l1 = Cache[int](MemoryCacheBackend())
354 l2 = Cache[int](MemoryCacheBackend())
355 tc = TieredCache(l1, l2)
356 await l2.set("k", 42)
357 # Not in L1, should hit L2 and promote
358 assert await tc.get("k") == 42
359 assert await l1.get("k") == 42 # promoted
361 @pytest.mark.asyncio
362 async def test_get_l2_no_promote(self):
363 l1 = Cache[int](MemoryCacheBackend())
364 l2 = Cache[int](MemoryCacheBackend())
365 tc = TieredCache(l1, l2, promote_on_read=False)
366 await l2.set("k", 42)
367 assert await tc.get("k") == 42
368 assert await l1.get("k") is None # not promoted
370 @pytest.mark.asyncio
371 async def test_set(self):
372 l1 = Cache[int](MemoryCacheBackend())
373 l2 = Cache[int](MemoryCacheBackend())
374 tc = TieredCache(l1, l2)
375 await tc.set("k", 99)
376 assert await l1.get("k") == 99
377 assert await l2.get("k") == 99
379 @pytest.mark.asyncio
380 async def test_delete(self):
381 l1 = Cache[int](MemoryCacheBackend())
382 l2 = Cache[int](MemoryCacheBackend())
383 tc = TieredCache(l1, l2)
384 await tc.set("k", 1)
385 assert await tc.delete("k")
386 assert await l1.get("k") is None
387 assert await l2.get("k") is None
389 @pytest.mark.asyncio
390 async def test_clear(self):
391 l1 = Cache[int](MemoryCacheBackend())
392 l2 = Cache[int](MemoryCacheBackend())
393 tc = TieredCache(l1, l2)
394 await tc.set("a", 1)
395 await tc.clear()
396 assert await l1.get("a") is None
398 @pytest.mark.asyncio
399 async def test_stats(self):
400 l1 = Cache[int](MemoryCacheBackend())
401 l2 = Cache[int](MemoryCacheBackend())
402 tc = TieredCache(l1, l2)
403 stats = tc.stats
404 assert "l1" in stats
405 assert "l2" in stats
408# ============================================================================
409# cached decorator
410# ============================================================================
412class TestCachedDecorator:
413 @pytest.mark.asyncio
414 async def test_cached(self):
415 c = Cache[int](MemoryCacheBackend())
416 call_count = 0
418 @cached(c, key_prefix="test")
419 async def compute(x: int) -> int:
420 nonlocal call_count
421 call_count += 1
422 return x * 2
424 v1 = await compute(5)
425 v2 = await compute(5)
426 assert v1 == 10
427 assert v2 == 10
428 assert call_count == 1
430 @pytest.mark.asyncio
431 async def test_different_args_bypass_cache(self):
432 c = Cache[int](MemoryCacheBackend())
433 call_count = 0
435 @cached(c)
436 async def compute(x: int) -> int:
437 nonlocal call_count
438 call_count += 1
439 return x
441 await compute(1)
442 await compute(2)
443 assert call_count == 2
445 @pytest.mark.asyncio
446 async def test_custom_key_builder(self):
447 c = Cache[str](MemoryCacheBackend())
448 call_count = 0
450 @cached(c, key_builder=lambda user_id: f"user:{user_id}")
451 async def fetch(user_id: str) -> str:
452 nonlocal call_count
453 call_count += 1
454 return f"data-{user_id}"
456 v1 = await fetch("u1")
457 assert v1 == "data-u1"
458 _ = await fetch("u1")
459 assert call_count == 1
462# ============================================================================
463# _build_signature
464# ============================================================================
466class TestBuildSignature:
467 def test_short(self):
468 sig = _build_signature((1, 2), {"name": "test"})
469 assert "1" in sig
470 assert "name=test" in sig
472 def test_long_uses_md5(self):
473 long_arg = "x" * 300
474 sig = _build_signature((long_arg,), {})
475 assert len(sig) == 32 # MD5 hex length
478# ============================================================================
479# RedisCacheBackend (light — import check only)
480# ============================================================================
482class TestRedisCacheBackend:
483 def test_init(self):
484 b = RedisCacheBackend(url="redis://localhost:6379/0")
485 assert b._url == "redis://localhost:6379/0"
487 def test_key_prefix(self):
488 b = RedisCacheBackend(prefix="test:")
489 assert b._key("foo") == "test:foo"