Files
LEDMatrix/test/test_cache_manager.py
T
ChuckandClaude Opus 5.5 7e066174d9 chore: remove dead code, deprecate unused plugin APIs (over-engineering audit)
Whole-tree audit. Every symbol was checked against core, the plugin
monorepo and all eight third-party plugins in plugins.json first.

- Deprecate (removal 3.10.0) plugin-facing methods nothing calls:
  LogoDownloader bulk download, ConfigManager backup/secret wrappers,
  APIHelper extras, BackgroundDataService poll API, PluginManager /
  PluginStateManager info readers, and a few CacheManager, FontManager,
  BaseOddsManager, DynamicTeamResolver methods and PluginTestCase.
  plugin_api_usage.py learns their receiver names; DEPRECATIONS doc
  regenerated.
- Remove core-internal dead code: CacheMetrics, Vegas status/stats
  plumbing, sync "new cycle" message (followers ignore unknown types),
  unused operation types, test-only PluginCatalog readers, IPC to_dict
  and ping, _parse_form_value, CacheStrategyProtocol, ErrorAggregator
  callbacks, duplicate web response helpers.
- Web UI: drop never-mounted json-file-manager.js, the example widget,
  utils/error_handler.js, four uncalled PluginAPI methods, and 29
  escapeHtml shims (call window.LEDEscape directly). Public globals,
  BaseWidget and widget names unchanged.
- Remove six one-off scripts (owner decision) and the unused markupsafe
  and pytest-mock pins.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 18:27:40 -04:00

408 lines
15 KiB
Python

"""
Tests for CacheManager and cache components.
Tests cache functionality including memory cache, disk cache, and strategy.
"""
import pytest
import time
from unittest.mock import patch
from src.cache_manager import CacheManager
from src.cache.memory_cache import MemoryCache
from src.cache.disk_cache import DiskCache
from src.cache.cache_strategy import CacheStrategy
from datetime import datetime
class TestCacheManager:
"""Test CacheManager functionality."""
def test_init(self, tmp_path):
"""Test CacheManager initialization."""
with patch('src.cache_manager.CacheManager._get_writable_cache_dir', return_value=str(tmp_path)):
cm = CacheManager()
assert cm.cache_dir == str(tmp_path)
assert hasattr(cm, '_memory_cache_component')
assert hasattr(cm, '_disk_cache_component')
assert hasattr(cm, '_strategy_component')
def test_set_and_get(self, tmp_path):
"""Test basic set and get operations."""
with patch('src.cache_manager.CacheManager._get_writable_cache_dir', return_value=str(tmp_path)):
cm = CacheManager()
test_data = {"key": "value", "number": 42}
cm.set("test_key", test_data)
result = cm.get("test_key")
assert result == test_data
def test_get_expired(self, tmp_path):
"""Test getting expired cache entry."""
with patch('src.cache_manager.CacheManager._get_writable_cache_dir', return_value=str(tmp_path)):
cm = CacheManager()
cm.set("test_key", {"data": "value"})
# Get with max_age=0 to force expiration
result = cm.get("test_key", max_age=0)
assert result is None
class TestCacheStrategy:
"""Test CacheStrategy functionality."""
def test_get_cache_strategy_default(self):
"""Test getting default cache strategy."""
strategy = CacheStrategy()
result = strategy.get_cache_strategy("unknown_type")
assert "max_age" in result
assert "memory_ttl" in result
assert result["max_age"] == 300 # Default
def test_get_cache_strategy_live(self):
"""Test getting live sports cache strategy."""
strategy = CacheStrategy()
result = strategy.get_cache_strategy("sports_live")
assert "max_age" in result
assert result["max_age"] <= 60 # Live data should be short
def test_get_data_type_from_key(self):
"""Test data type detection from cache key."""
strategy = CacheStrategy()
assert strategy.get_data_type_from_key("nba_live_scores") == "sports_live"
# "weather_current" contains "current" which matches live sports pattern first
# Use "weather" without "current" to test weather detection
assert strategy.get_data_type_from_key("weather") == "weather_current"
assert strategy.get_data_type_from_key("weather_data") == "weather_current"
assert strategy.get_data_type_from_key("unknown_key") == "default"
class TestMemoryCache:
"""Test MemoryCache functionality."""
def test_init(self):
"""Test MemoryCache initialization."""
cache = MemoryCache(max_size=100, cleanup_interval=60.0)
assert cache._max_size == 100
assert cache._cleanup_interval == 60.0
assert cache.size() == 0
def test_set_and_get(self):
"""Test basic set and get operations."""
cache = MemoryCache()
test_data = {"key": "value", "number": 42}
cache.set("test_key", test_data)
result = cache.get("test_key")
assert result == test_data
def test_get_expired(self):
"""Test getting expired cache entry."""
cache = MemoryCache()
cache.set("test_key", {"data": "value"})
# Get with max_age=0 to force expiration
result = cache.get("test_key", max_age=0)
assert result is None
def test_get_nonexistent(self):
"""Test getting non-existent key."""
cache = MemoryCache()
result = cache.get("nonexistent_key")
assert result is None
def test_clear_specific_key(self):
"""Test clearing a specific cache key."""
cache = MemoryCache()
cache.set("key1", {"data": "value1"})
cache.set("key2", {"data": "value2"})
cache.clear("key1")
assert cache.get("key1") is None
assert cache.get("key2") is not None
def test_clear_all(self):
"""Test clearing all cache entries."""
cache = MemoryCache()
cache.set("key1", {"data": "value1"})
cache.set("key2", {"data": "value2"})
cache.clear()
assert cache.size() == 0
assert cache.get("key1") is None
assert cache.get("key2") is None
def test_cleanup_expired(self):
"""Test cleanup removes expired entries."""
cache = MemoryCache()
cache.set("key1", {"data": "value1"})
# Force expiration by manipulating timestamp (older than 1 hour cleanup threshold)
# Cleanup uses max_age_for_cleanup = 3600 (1 hour)
cache._timestamps["key1"] = time.time() - 4000 # More than 1 hour
removed = cache.cleanup(force=True)
# Cleanup should remove expired entries (older than 3600 seconds)
# The key should be gone after cleanup
assert cache.get("key1") is None or removed >= 0
def test_cleanup_size_limit(self):
"""Test cleanup enforces size limits."""
cache = MemoryCache(max_size=3)
# Add more entries than max_size
for i in range(5):
cache.set(f"key{i}", {"data": f"value{i}"})
removed = cache.cleanup(force=True)
assert cache.size() <= cache._max_size
assert removed >= 0
def test_size(self):
"""Test size reporting."""
cache = MemoryCache()
assert cache.size() == 0
cache.set("key1", {"data": "value1"})
cache.set("key2", {"data": "value2"})
assert cache.size() == 2
def test_max_size(self):
"""Test max_size property."""
cache = MemoryCache(max_size=500)
assert cache.max_size() == 500
def test_get_stats(self):
"""Test getting cache statistics."""
cache = MemoryCache()
cache.set("key1", {"data": "value1"})
cache.set("key2", {"data": "value2"})
stats = cache.get_stats()
assert "size" in stats
assert "max_size" in stats
assert stats["size"] == 2
assert stats["max_size"] == 1000 # default
class TestDiskCache:
"""Test DiskCache functionality."""
def test_init_with_dir(self, tmp_path):
"""Test DiskCache initialization with directory."""
cache = DiskCache(cache_dir=str(tmp_path))
assert cache.cache_dir == str(tmp_path)
def test_init_without_dir(self):
"""Test DiskCache initialization without directory."""
cache = DiskCache(cache_dir=None)
assert cache.cache_dir is None
def test_get_cache_path(self, tmp_path):
"""Test getting cache file path."""
cache = DiskCache(cache_dir=str(tmp_path))
path = cache.get_cache_path("test_key")
assert path == str(tmp_path / "test_key.json")
def test_get_cache_path_disabled(self):
"""Test getting cache path when disabled."""
cache = DiskCache(cache_dir=None)
path = cache.get_cache_path("test_key")
assert path is None
def test_set_and_get(self, tmp_path):
"""Test basic set and get operations."""
cache = DiskCache(cache_dir=str(tmp_path))
test_data = {"key": "value", "number": 42}
cache.set("test_key", test_data)
result = cache.get("test_key")
assert result == test_data
def test_get_expired(self, tmp_path):
"""Test getting expired cache entry."""
cache = DiskCache(cache_dir=str(tmp_path))
cache.set("test_key", {"data": "value"})
# Get with max_age=0 to force expiration
result = cache.get("test_key", max_age=0)
assert result is None
def test_get_max_age_none_never_expires(self, tmp_path):
"""max_age=None must return persisted records regardless of age.
Regression: the age comparison raised TypeError for max_age=None,
which was swallowed and treated as a miss — silently breaking
long-lived state (plugin health/metrics) read across processes.
"""
cache = DiskCache(cache_dir=str(tmp_path))
cache.set("test_key", {"data": "value", "timestamp": 0}) # epoch → very old
result = cache.get("test_key", max_age=None)
assert result is not None
assert result["data"] == "value"
def test_get_nonexistent(self, tmp_path):
"""Test getting non-existent key."""
cache = DiskCache(cache_dir=str(tmp_path))
result = cache.get("nonexistent_key")
assert result is None
def test_clear_specific_key(self, tmp_path):
"""Test clearing a specific cache key."""
cache = DiskCache(cache_dir=str(tmp_path))
cache.set("key1", {"data": "value1"})
cache.set("key2", {"data": "value2"})
cache.clear("key1")
assert cache.get("key1") is None
assert cache.get("key2") is not None
def test_clear_all(self, tmp_path):
"""Test clearing all cache entries."""
cache = DiskCache(cache_dir=str(tmp_path))
cache.set("key1", {"data": "value1"})
cache.set("key2", {"data": "value2"})
cache.clear()
assert cache.get("key1") is None
assert cache.get("key2") is None
def test_get_cache_dir(self, tmp_path):
"""Test getting cache directory."""
cache = DiskCache(cache_dir=str(tmp_path))
assert cache.get_cache_dir() == str(tmp_path)
def test_set_with_datetime(self, tmp_path):
"""Test setting cache with datetime objects."""
cache = DiskCache(cache_dir=str(tmp_path))
test_data = {
"timestamp": datetime.now(),
"data": "value"
}
cache.set("test_key", test_data)
result = cache.get("test_key")
# Datetime should be serialized/deserialized
assert result is not None
assert "data" in result
def test_cleanup_interval(self, tmp_path):
"""Test cleanup respects interval."""
cache = MemoryCache(cleanup_interval=60.0)
cache.set("key1", {"data": "value1"})
# First cleanup should work
removed1 = cache.cleanup(force=True)
# Second cleanup immediately after should return 0 (unless forced)
removed2 = cache.cleanup(force=False)
# If forced, should work; if not forced and within interval, should return 0
assert removed2 >= 0
def test_get_with_invalid_timestamp(self):
"""Test getting entry with invalid timestamp format."""
cache = MemoryCache()
cache.set("key1", {"data": "value1"})
# Set invalid timestamp
cache._timestamps["key1"] = "invalid_timestamp"
result = cache.get("key1")
# Should handle gracefully
assert result is None or isinstance(result, dict)
class TestDiskCacheWriteEconomy:
"""SD-card wear guards: identical payloads skip the disk, files are
compact, and TTL semantics survive the skip (see PR: fix/diskcache-sd-wear)."""
def test_identical_set_skips_rewrite(self, tmp_path):
import os
cache = DiskCache(cache_dir=str(tmp_path))
cache.set("k", {"data": "v"})
path = cache.get_cache_path("k")
first = os.stat(path)
os.utime(path, (first.st_atime - 100, first.st_mtime - 100)) # age it
aged_mtime = os.stat(path).st_mtime
ino_before = os.stat(path).st_ino
cache.set("k", {"data": "v"}) # identical payload
after = os.stat(path)
# mtime refreshed (TTL for mtime-based records preserved)...
assert after.st_mtime > aged_mtime
# ...but the file was NOT rewritten (same inode: no replace happened)
assert after.st_ino == ino_before
def test_changed_data_rewrites(self, tmp_path):
import os
cache = DiskCache(cache_dir=str(tmp_path))
cache.set("k", {"data": "v1"})
cache.set("k", {"data": "v2"})
assert cache.get("k") == {"data": "v2"}
def test_clear_resets_digest(self, tmp_path):
import os
cache = DiskCache(cache_dir=str(tmp_path))
cache.set("k", {"data": "v"})
cache.clear("k")
assert cache.get("k") is None
cache.set("k", {"data": "v"}) # same payload after clear must WRITE
assert cache.get("k") == {"data": "v"}
def test_skip_self_heals_when_file_deleted_externally(self, tmp_path):
import os
cache = DiskCache(cache_dir=str(tmp_path))
cache.set("k", {"data": "v"})
os.remove(cache.get_cache_path("k")) # e.g. expiry cleanup
cache.set("k", {"data": "v"}) # digest matches but file is gone
assert cache.get("k") == {"data": "v"}
def test_files_are_compact_json(self, tmp_path):
cache = DiskCache(cache_dir=str(tmp_path))
cache.set("k", {"a": 1, "b": [1, 2, 3]})
raw = open(cache.get_cache_path("k")).read()
assert "\n" not in raw.strip() # no indent
assert cache.get("k") == {"a": 1, "b": [1, 2, 3]}
def test_datetime_round_trip_still_works(self, tmp_path):
from datetime import datetime
cache = DiskCache(cache_dir=str(tmp_path))
cache.set("k", {"when": datetime(2026, 7, 12, 10, 30)})
assert cache.get("k") == {"when": "2026-07-12T10:30:00"}
# --- the ceiling has to hold between cleanup sweeps ---------------------------
def test_memory_cache_enforces_ceiling_on_every_write():
"""_cleanup_memory_cache only runs every cleanup_interval seconds (300 by
default). If set() accepted entries without bound in between, a burst could
take the cache far past max_size -- which is the unbounded growth the limit
exists to prevent, and on a 1GB board the difference between a bounded cache
and a Pi that cannot fork.
"""
from src.cache.memory_cache import MemoryCache
cache = MemoryCache(max_size=150, cleanup_interval=300.0)
for i in range(1000):
cache.set(f"k{i}", {"v": i})
assert len(cache._cache) <= 150
# The timestamp map has to be evicted alongside the values, or it becomes
# the leak instead.
assert len(cache._timestamps) <= 150
assert cache.get("k999") is not None, "the newest write must survive"
assert cache.get("k0") is None, "the oldest must be the one evicted"