mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-07 07:36:37 +00:00
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>
408 lines
15 KiB
Python
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"
|