""" 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()