245 lines
8.4 KiB
Python
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()
|