Files
pyWebLayout/tests/core/test_cache.py
T

245 lines
8.4 KiB
Python

"""
Unit tests for the bounded usage-ranked caches.
Covers the guarantees the text rendering path depends on: that the bounds are never
exceeded, that eviction prefers the least-used entries, that aging lets a new
working set displace an old one, and that document-frequency seeding survives a
scan of unfamiliar keys.
"""
import unittest
from pyWebLayout.core.cache import (
UsageCache,
SizedUsageCache,
DEFAULT_AGING_INTERVAL,
)
class TestUsageCache(unittest.TestCase):
"""Entry-count-bounded cache."""
def test_rejects_invalid_bounds(self):
for bad in (0, -1):
with self.assertRaises(ValueError):
UsageCache(bad)
with self.assertRaises(ValueError):
UsageCache(4, aging_interval=0)
with self.assertRaises(ValueError):
UsageCache(4, eviction_sample=0)
def test_stores_and_returns_values(self):
cache = UsageCache(4)
cache.put('a', 1)
self.assertEqual(cache.get('a'), 1)
self.assertIsNone(cache.get('missing'))
self.assertIn('a', cache)
self.assertEqual(len(cache), 1)
def test_never_exceeds_max_entries(self):
cache = UsageCache(10)
for i in range(500):
cache.put(i, i)
self.assertLessEqual(len(cache), 10)
self.assertEqual(cache.stats()['entries'], 10)
def test_evicts_least_used(self):
# One hot key among many cold ones must survive a long cold scan. The
# sample is smaller than the cache, so this is probabilistic in principle;
# a hot key's count is far enough above the rest to make it reliable.
cache = UsageCache(20, eviction_sample=8)
cache.put('hot', 'value')
for _ in range(200):
cache.get('hot')
for i in range(400):
cache.put(f'cold{i}', i)
cache.get('hot')
self.assertEqual(cache.get('hot'), 'value')
def test_repeated_put_does_not_duplicate(self):
cache = UsageCache(10)
for _ in range(50):
cache.put('a', 1)
self.assertEqual(len(cache), 1)
def test_put_updates_existing_value(self):
cache = UsageCache(10)
cache.put('a', 1)
cache.put('a', 2)
self.assertEqual(cache.get('a'), 2)
def test_seeded_count_outranks_fresh_entries(self):
"""A document-frequency seed must survive a scan of unseen keys."""
cache = UsageCache(20, eviction_sample=8)
cache.put('frequent', 'value', count=5000)
for i in range(400):
cache.put(f'new{i}', i)
self.assertEqual(cache.get('frequent'), 'value')
def test_aging_lets_a_new_working_set_take_over(self):
"""Without aging, stale high counts lock the cache permanently."""
cache = UsageCache(20, aging_interval=50, eviction_sample=8)
for i in range(20):
cache.put(f'old{i}', i, count=10000)
# A completely different working set, each key used a few times.
for round_ in range(60):
for i in range(10):
key = f'new{i}'
if cache.get(key) is None:
cache.put(key, i)
survivors = sum(1 for i in range(10) if f'new{i}' in cache)
self.assertGreater(survivors, 0,
"aging should let the new working set displace the old")
self.assertGreater(cache.stats()['agings'], 0)
def test_aging_can_be_disabled(self):
cache = UsageCache(10, aging_interval=None)
for i in range(100):
cache.put(i, i)
self.assertEqual(cache.stats()['agings'], 0)
def test_resize_evicts_immediately(self):
cache = UsageCache(100)
for i in range(100):
cache.put(i, i)
cache.resize(10)
self.assertEqual(len(cache), 10)
with self.assertRaises(ValueError):
cache.resize(0)
def test_clear_empties_but_keeps_counters(self):
cache = UsageCache(10)
cache.put('a', 1)
cache.get('a')
cache.clear()
self.assertEqual(len(cache), 0)
self.assertNotIn('a', cache)
self.assertEqual(cache.stats()['hits'], 1)
def test_stats_track_hits_and_misses(self):
cache = UsageCache(10)
cache.put('a', 1)
cache.get('a')
cache.get('a')
cache.get('b')
stats = cache.stats()
self.assertEqual(stats['hits'], 2)
self.assertEqual(stats['misses'], 1)
self.assertAlmostEqual(stats['hit_rate'], 2 / 3)
self.assertEqual(stats['max_entries'], 10)
def test_internal_slot_list_stays_consistent(self):
"""Eviction swaps the tail into the freed slot; indices must stay valid."""
cache = UsageCache(8)
for i in range(300):
cache.put(i, i)
for key in list(cache._entries):
self.assertEqual(cache._slots[cache._entries[key][2]], key)
self.assertEqual(len(cache._slots), len(cache._entries))
class TestSizedUsageCache(unittest.TestCase):
"""Byte-bounded cache, as used for glyph bitmaps."""
@staticmethod
def sizer(value):
return value
def test_rejects_invalid_bounds(self):
for bad in (0, -1):
with self.assertRaises(ValueError):
SizedUsageCache(bad, self.sizer)
def test_never_exceeds_max_bytes(self):
cache = SizedUsageCache(1000, self.sizer)
for i in range(500):
cache.put(i, 100)
self.assertLessEqual(cache.total_bytes, 1000)
def test_tracks_total_bytes(self):
cache = SizedUsageCache(1000, self.sizer)
cache.put('a', 100)
cache.put('b', 250)
self.assertEqual(cache.total_bytes, 350)
def test_oversized_value_is_not_retained(self):
"""One huge entry must not flush everything else out."""
cache = SizedUsageCache(1000, self.sizer)
cache.put('small', 100)
cache.put('huge', 5000)
self.assertNotIn('huge', cache)
self.assertIn('small', cache)
self.assertEqual(cache.total_bytes, 100)
def test_replacing_a_value_remeasures_it(self):
cache = SizedUsageCache(1000, self.sizer)
cache.put('a', 100)
cache.put('a', 300)
self.assertEqual(cache.total_bytes, 300)
self.assertEqual(len(cache), 1)
def test_evicts_least_used(self):
cache = SizedUsageCache(1000, self.sizer, eviction_sample=8)
cache.put('hot', 100)
for _ in range(200):
cache.get('hot')
for i in range(400):
cache.put(f'cold{i}', 100)
cache.get('hot')
self.assertIn('hot', cache)
def test_seeded_count_outranks_fresh_entries(self):
cache = SizedUsageCache(1000, self.sizer, eviction_sample=8)
cache.put('frequent', 100, count=5000)
for i in range(400):
cache.put(f'new{i}', 100)
self.assertIn('frequent', cache)
def test_resize_evicts_immediately(self):
cache = SizedUsageCache(10000, self.sizer)
for i in range(100):
cache.put(i, 100)
cache.resize(500)
self.assertLessEqual(cache.total_bytes, 500)
def test_clear_resets_byte_accounting(self):
cache = SizedUsageCache(1000, self.sizer)
cache.put('a', 100)
cache.clear()
self.assertEqual(cache.total_bytes, 0)
self.assertEqual(len(cache), 0)
def test_stats_report_bounds(self):
cache = SizedUsageCache(1000, self.sizer)
cache.put('a', 100)
stats = cache.stats()
self.assertEqual(stats['total_bytes'], 100)
self.assertEqual(stats['max_bytes'], 1000)
self.assertEqual(stats['entries'], 1)
def test_bookkeeping_stays_consistent_under_churn(self):
"""Byte total and slot list must not drift over many evictions."""
cache = SizedUsageCache(2000, self.sizer, aging_interval=97)
for i in range(2000):
cache.put(i, (i % 7 + 1) * 50)
if i % 3 == 0:
cache.get(i)
self.assertEqual(cache.total_bytes,
sum(cache._sizes[k] for k in cache._entries))
self.assertEqual(len(cache._slots), len(cache._entries))
self.assertLessEqual(cache.total_bytes, cache.max_bytes)
class TestDefaults(unittest.TestCase):
def test_aging_is_enabled_by_default(self):
self.assertIsNotNone(DEFAULT_AGING_INTERVAL)
self.assertGreater(DEFAULT_AGING_INTERVAL, 0)
self.assertIsNotNone(UsageCache(4)._aging_interval)
if __name__ == '__main__':
unittest.main()