Coverage for agentos/tests/test_serial_cache.py: 0%
361 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-07 00:45 +0800
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-07 00:45 +0800
1"""Tests for agentos.tools.serial_cache — Serializer, TTLCache, SmartCache."""
3import json
4import pickle
5import pytest
6import threading
7import time
8from agentos.tools.serial_cache import (
9 EvictionPolicy,
10 SerialFormat,
11 Serializer,
12 SmartCache,
13 TTLCache,
14)
17# ============================================================================
18# SerialFormat
19# ============================================================================
21class TestSerialFormat:
22 def test_enum_values(self):
23 assert SerialFormat.JSON.value == "json"
24 assert SerialFormat.PICKLE.value == "pickle"
25 assert SerialFormat.MSGPACK.value == "msgpack"
26 assert SerialFormat.AUTO.value == "auto"
28 def test_detect_explicit_json(self):
29 fmt = SerialFormat.JSON
30 assert fmt.detect(b'{"a":1}') == SerialFormat.JSON
32 def test_detect_explicit_pickle(self):
33 fmt = SerialFormat.PICKLE
34 assert fmt.detect(b"\x80\x04") == SerialFormat.PICKLE
36 def test_detect_auto_json(self):
37 fmt = SerialFormat.AUTO
38 assert fmt.detect(b'{"a":1}') == SerialFormat.JSON
39 assert fmt.detect(b"[1,2,3]") == SerialFormat.JSON
41 def test_detect_auto_pickle(self):
42 fmt = SerialFormat.AUTO
43 data = pickle.dumps({"x": 1})
44 assert fmt.detect(data) == SerialFormat.PICKLE
46 def test_detect_auto_unknown(self):
47 fmt = SerialFormat.AUTO
48 with pytest.raises(ValueError, match="Cannot auto-detect"):
49 fmt.detect(b"\xff\xfe")
52# ============================================================================
53# Serializer
54# ============================================================================
56class TestSerializer:
57 def test_default_format(self):
58 s = Serializer()
59 assert s._fmt == SerialFormat.JSON
61 def test_custom_format(self):
62 s = Serializer(fmt=SerialFormat.PICKLE)
63 assert s._fmt == SerialFormat.PICKLE
65 def test_auto_format_falls_back_to_json(self):
66 s = Serializer(fmt=SerialFormat.AUTO)
67 data = s.dumps({"a": 1})
68 result = s.loads(data)
69 assert result == {"a": 1}
71 def test_dumps_json(self):
72 s = Serializer(fmt=SerialFormat.JSON)
73 data = s.dumps({"hello": "world"})
74 assert json.loads(data) == {"hello": "world"}
76 def test_dumps_pickle(self):
77 s = Serializer(fmt=SerialFormat.PICKLE)
78 data = s.dumps({"x": 42})
79 assert pickle.loads(data) == {"x": 42}
81 def test_loads_json(self):
82 s = Serializer()
83 data = json.dumps({"a": 1, "b": 2}).encode("utf-8")
84 result = s.loads(data)
85 assert result == {"a": 1, "b": 2}
87 def test_loads_pickle(self):
88 s = Serializer()
89 data = pickle.dumps({"z": 99})
90 result = s.loads(data)
91 assert result == {"z": 99}
93 def test_loads_explicit_format(self):
94 s = Serializer()
95 data = json.dumps([1, 2, 3]).encode("utf-8")
96 result = s.loads(data, fmt=SerialFormat.JSON)
97 assert result == [1, 2, 3]
99 def test_roundtrip_json(self):
100 s = Serializer(fmt=SerialFormat.JSON)
101 obj = {"nested": {"key": [1, 2, 3]}}
102 assert s.loads(s.dumps(obj)) == obj
104 def test_roundtrip_pickle(self):
105 s = Serializer(fmt=SerialFormat.PICKLE)
106 obj = {"tuple": (1, 2), "set": {3, 4}}
107 assert s.loads(s.dumps(obj)) == obj
109 def test_stats(self):
110 s = Serializer()
111 s.dumps({"a": 1})
112 s.loads(json.dumps({"b": 2}).encode())
113 st = s.stats
114 assert st["total_serialized"] == 1
115 assert st["total_deserialized"] == 1
116 assert st["format"] == "json"
118 def test_unsupported_format(self):
119 s = Serializer(fmt=SerialFormat.PICKLE)
120 # Create a serializer with a bad internal state and test dumps
121 with pytest.raises(ValueError, match="Unsupported"):
122 s.dumps({}, use_msgpack=False)
123 s._fmt = "bad_fmt"
124 s.dumps({})
126 def test_use_msgpack_flag(self):
127 pytest.importorskip("msgpack")
128 s = Serializer(fmt=SerialFormat.JSON)
129 data = s.dumps({"a": 1}, use_msgpack=True)
130 import msgpack
131 assert msgpack.unpackb(data) == {"a": 1}
133 def test_loads_msgpack(self):
134 pytest.importorskip("msgpack")
135 import msgpack
136 s = Serializer()
137 data = msgpack.packb({"m": "p"})
138 result = s.loads(data)
139 assert result == {"m": "p"}
141 def test_stats_pickle_format(self):
142 s = Serializer(fmt=SerialFormat.PICKLE)
143 s.dumps({"x": 1})
144 assert s.stats["format"] == "pickle"
147# ============================================================================
148# EvictionPolicy
149# ============================================================================
151class TestEvictionPolicy:
152 def test_enum_values(self):
153 assert EvictionPolicy.LRU.value == "lru"
154 assert EvictionPolicy.LFU.value == "lfu"
155 assert EvictionPolicy.TTL_ONLY.value == "ttl_only"
158# ============================================================================
159# TTLCache — Basic Operations
160# ============================================================================
162class TestTTLCacheBasic:
163 def test_defaults(self):
164 cache = TTLCache[int]()
165 assert cache._max_size == 1000
166 assert cache._ttl == 300.0
167 assert cache._policy == EvictionPolicy.LRU
168 assert cache.size == 0
170 def test_custom_params(self):
171 cache = TTLCache[str](max_size=10, ttl=60.0, policy=EvictionPolicy.LFU)
172 assert cache._max_size == 10
173 assert cache._ttl == 60.0
174 assert cache._policy == EvictionPolicy.LFU
176 def test_set_get(self):
177 cache = TTLCache[int]()
178 cache.set("a", 1)
179 assert cache.get("a") == 1
181 def test_get_missing(self):
182 cache = TTLCache[str]()
183 assert cache.get("no-key") is None
185 def test_set_overwrite(self):
186 cache = TTLCache[int]()
187 cache.set("a", 1)
188 cache.set("a", 99)
189 assert cache.get("a") == 99
190 assert cache.size == 1
192 def test_delete(self):
193 cache = TTLCache[int]()
194 cache.set("x", 42)
195 assert cache.delete("x") is True
196 assert cache.get("x") is None
197 assert cache.size == 0
199 def test_delete_missing(self):
200 cache = TTLCache[int]()
201 assert cache.delete("nope") is False
203 def test_clear(self):
204 cache = TTLCache[int]()
205 cache.set("a", 1)
206 cache.set("b", 2)
207 cache.clear()
208 assert cache.size == 0
209 assert cache.get("a") is None
211 def test_size(self):
212 cache = TTLCache[int]()
213 assert cache.size == 0
214 cache.set("a", 1)
215 assert cache.size == 1
216 cache.set("b", 2)
217 assert cache.size == 2
220# ============================================================================
221# TTLCache — TTL Expiration
222# ============================================================================
224class TestTTLCacheTTL:
225 def test_expired_entry(self):
226 cache = TTLCache[int](ttl=0.05)
227 cache.set("a", 1)
228 assert cache.get("a") == 1
229 time.sleep(0.1)
230 assert cache.get("a") is None
232 def test_custom_ttl_per_entry(self):
233 cache = TTLCache[int](ttl=10.0)
234 cache.set("a", 1, ttl=0.05)
235 assert cache.get("a") == 1
236 time.sleep(0.1)
237 assert cache.get("a") is None
239 def test_cleanup(self):
240 cache = TTLCache[int](ttl=0.05)
241 cache.set("a", 1)
242 cache.set("b", 2)
243 time.sleep(0.1)
244 removed = cache.cleanup()
245 assert removed == 2
246 assert cache.size == 0
248 def test_cleanup_partial(self):
249 cache = TTLCache[int](ttl=0.05)
250 cache.set("a", 1)
251 time.sleep(0.1)
252 cache.set("b", 2, ttl=10.0)
253 removed = cache.cleanup()
254 assert removed == 1
255 assert cache.size == 1
256 assert cache.get("b") == 2
259# ============================================================================
260# TTLCache — Eviction
261# ============================================================================
263class TestTTLCacheEviction:
264 def test_lru_eviction(self):
265 cache = TTLCache[int](max_size=2, ttl=300, policy=EvictionPolicy.LRU)
266 cache.set("a", 1)
267 cache.set("b", 2)
268 cache.get("a") # access "a" — now "b" is least recently used
269 cache.set("c", 3)
270 assert cache.get("b") is None
271 assert cache.get("a") == 1
272 assert cache.get("c") == 3
274 def test_lfu_eviction(self):
275 cache = TTLCache[int](max_size=2, ttl=300, policy=EvictionPolicy.LFU)
276 cache.set("a", 1)
277 cache.set("b", 2)
278 cache.get("b") # b access_count = 1
279 cache.get("b") # b access_count = 2
280 cache.get("a") # a access_count = 1
281 cache.set("c", 3)
282 # a has lowest access_count (1), should be evicted
283 assert cache.get("a") is None
284 assert cache.get("b") == 2
285 assert cache.get("c") == 3
287 def test_ttl_only_eviction(self):
288 cache = TTLCache[int](max_size=2, ttl=300, policy=EvictionPolicy.TTL_ONLY)
289 cache.set("a", 1)
290 cache.set("b", 2)
291 cache.get("b") # access b
292 cache.set("c", 3)
293 # TTL_ONLY evicts oldest (first inserted) regardless of access
294 assert cache.get("a") is None
295 assert cache.get("b") == 2
298# ============================================================================
299# TTLCache — Stats
300# ============================================================================
302class TestTTLCacheStats:
303 def test_default_stats(self):
304 cache = TTLCache[int](max_size=50, ttl=10, policy=EvictionPolicy.LFU)
305 s = cache.stats
306 assert s["size"] == 0
307 assert s["max_size"] == 50
308 assert s["ttl"] == 10
309 assert s["policy"] == "lfu"
310 assert s["hits"] == 0
311 assert s["misses"] == 0
312 assert s["evictions"] == 0
314 def test_hits_misses(self):
315 cache = TTLCache[int]()
316 cache.set("a", 1)
317 cache.get("a") # hit
318 cache.get("b") # miss
319 s = cache.stats
320 assert s["hits"] == 1
321 assert s["misses"] == 1
323 def test_hit_rate(self):
324 cache = TTLCache[int]()
325 cache.set("a", 1)
326 cache.get("a") # hit
327 cache.get("a") # hit
328 cache.get("b") # miss
329 s = cache.stats
330 assert s["hit_rate"] == round(2 / 3, 3)
332 def test_hit_rate_no_ops(self):
333 cache = TTLCache[int]()
334 s = cache.stats
335 assert s["hit_rate"] == 0.0
337 def test_evictions_count(self):
338 cache = TTLCache[int](max_size=2, ttl=300, policy=EvictionPolicy.LRU)
339 cache.set("a", 1)
340 cache.set("b", 2)
341 cache.set("c", 3) # evicts "a"
342 assert cache.stats["evictions"] == 1
345# ============================================================================
346# TTLCache — Thread Safety
347# ============================================================================
349class TestTTLCacheThreadSafety:
350 def test_concurrent_access(self):
351 cache = TTLCache[int]()
352 errors = []
354 def worker(start_idx):
355 try:
356 for i in range(start_idx, start_idx + 50):
357 cache.set(f"k{i}", i)
358 cache.get(f"k{i}")
359 cache.delete(f"k{i}")
360 except Exception as e:
361 errors.append(e)
363 threads = [threading.Thread(target=worker, args=(i * 100,)) for i in range(4)]
364 for t in threads:
365 t.start()
366 for t in threads:
367 t.join()
369 assert len(errors) == 0
372# ============================================================================
373# SmartCache
374# ============================================================================
376class TestSmartCache:
377 def test_get_missing(self):
378 sc = SmartCache[int]()
379 assert sc.get("x") is None
381 def test_get_or_compute(self):
382 sc = SmartCache[int]()
383 result = sc.get_or_compute("key", lambda: 42)
384 assert result == 42
385 # Second call should use cache
386 result = sc.get_or_compute("key", lambda: 99)
387 assert result == 42
389 def test_get_or_compute_complex(self):
390 sc = SmartCache[str](ttl=10.0)
391 calls = []
393 def factory():
394 calls.append(1)
395 return "computed"
397 r1 = sc.get_or_compute("k", factory)
398 assert r1 == "computed"
399 assert len(calls) == 1
400 r2 = sc.get_or_compute("k", factory)
401 assert r2 == "computed"
402 assert len(calls) == 1
404 def test_get_or_compute_expired(self):
405 sc = SmartCache[int](ttl=0.05)
406 sc.get_or_compute("k", lambda: 42)
407 time.sleep(0.1)
408 result = sc.get_or_compute("k", lambda: 99)
409 assert result == 99 # recomputed after expiry
411 def test_get_or_compute_custom_ttl(self):
412 sc = SmartCache[int]()
413 r1 = sc.get_or_compute("k", lambda: 42, ttl=0.05)
414 assert r1 == 42
415 time.sleep(0.1)
416 r2 = sc.get_or_compute("k", lambda: 99)
417 assert r2 == 99
419 def test_set_and_get(self):
420 sc = SmartCache[int]()
421 sc.set("x", 100)
422 assert sc.get("x") == 100
424 def test_delete(self):
425 sc = SmartCache[int]()
426 sc.set("x", 1)
427 assert sc.delete("x") is True
428 assert sc.get("x") is None
430 def test_clear(self):
431 sc = SmartCache[int]()
432 sc.set("a", 1)
433 sc.set("b", 2)
434 sc.clear()
435 assert sc.size == 0
437 def test_size(self):
438 sc = SmartCache[int]()
439 assert sc.size == 0
440 sc.set("a", 1)
441 assert sc.size == 1
442 sc.set("b", 2)
443 assert sc.size == 2
445 def test_stats(self):
446 sc = SmartCache[int]()
447 sc.get_or_compute("k", lambda: 42)
448 s = sc.stats
449 assert s["size"] == 1
450 assert s["hits"] >= 0
451 assert s["max_size"] == 1000
453 def test_dump_load(self):
454 sc = SmartCache[str]()
455 sc.set("a", "hello")
456 sc.set("b", "world")
458 data = sc.dump()
459 assert len(data) > 0
461 sc2 = SmartCache[str]()
462 loaded = sc2.load(data)
463 assert loaded == 2
464 assert sc2.get("a") == "hello"
465 assert sc2.get("b") == "world"
467 def test_dump_load_expired_filters(self):
468 sc = SmartCache[int]()
469 sc.set("live", 42, ttl=10.0)
470 sc.get_or_compute("dead", lambda: 1, ttl=0.01)
471 time.sleep(0.05)
473 data = sc.dump()
474 sc2 = SmartCache[int]()
475 loaded = sc2.load(data)
476 assert loaded == 1
477 assert sc2.get("live") == 42
478 assert sc2.get("dead") is None
480 def test_custom_params_passthrough(self):
481 sc = SmartCache[int](max_size=5, ttl=30.0, policy=EvictionPolicy.LFU)
482 assert sc.size == 0
483 assert sc._cache._max_size == 5