Files
LEDMatrix/test/conftest.py
T
9e3b5f366e fix(core): harden text-measurement caches; surface snapshot failures (#400)
* fix(core): harden text-measurement caches; surface snapshot failures

Deep-dive findings, all three latent on every 24/7 install:

- font_manager.metrics_cache and display_manager._text_width_cache were
  unbounded dicts keyed by (text, id(font)). Two problems: keys embed
  the measured TEXT, so ever-changing strings (a clock, a live score, a
  ticker) grow them without limit; and id()-keying without holding a
  reference means a garbage-collected font's id can be recycled by a
  DIFFERENT font, silently returning wrong widths/metrics (classic
  plugins create fonts per render, so this is reachable). Both caches
  are now LRU-bounded (1024) and pin the font in the entry so its id
  stays valid. metrics_cache also keyed on the text itself instead of
  hash(text), removing a collision path.

- _write_snapshot_if_due logged failures at DEBUG — invisible at the
  default level. The snapshot's mtime is the web UI's display mirror
  AND its hardware-liveness proxy, so a quiet failure freezes the
  mirror and makes health checks lie (seen in the field: a stale
  root-owned /tmp file froze it for a day). Failures now WARN, rate-
  limited to once per 5 minutes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FqzC1nzTWL4kaqgMaQZFam

* test: sync mock cache manager signature with CacheManager.get

test_circuit_breaker has been failing on main: plugin_health passes
memory_ttl= to cache_manager.get(), and the conftest mock's signature
was never updated — the same component/double drift class as the
monitored_update bug (#392).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FqzC1nzTWL4kaqgMaQZFam

---------

Co-authored-by: Chuck <chuck@example.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 08:59:41 -04:00

317 lines
10 KiB
Python

"""
Pytest configuration and fixtures for LEDMatrix tests.
Provides common fixtures for mocking core components and test setup.
"""
import pytest
import sys
from pathlib import Path
from unittest.mock import Mock, MagicMock
from typing import Dict, Any, Optional
# Add project root to path
project_root = Path(__file__).parent.parent
if str(project_root) not in sys.path:
sys.path.insert(0, str(project_root))
@pytest.fixture
def mock_display_manager():
"""Create a mock DisplayManager for testing."""
mock = MagicMock()
mock.width = 128
mock.height = 32
mock.clear = Mock()
mock.draw_text = Mock()
mock.draw_image = Mock()
mock.update_display = Mock()
mock.get_font = Mock(return_value=None)
return mock
@pytest.fixture
def mock_cache_manager():
"""Create a mock CacheManager for testing."""
mock = MagicMock()
mock._memory_cache = {}
mock._memory_cache_timestamps = {}
mock.cache_dir = "/tmp/test_cache"
def mock_get(key: str, max_age: Optional[int] = 300,
memory_ttl: Optional[int] = None) -> Optional[Dict]:
# Signature mirrors CacheManager.get — keep in sync or callers
# passing keyword args (health tracker, resource monitor) break
# only in tests, hiding real-API compatibility.
return mock._memory_cache.get(key)
def mock_set(key: str, data: Dict, ttl: Optional[int] = None) -> None:
mock._memory_cache[key] = data
def mock_clear(key: Optional[str] = None) -> None:
if key:
mock._memory_cache.pop(key, None)
else:
mock._memory_cache.clear()
mock.get = Mock(side_effect=mock_get)
mock.set = Mock(side_effect=mock_set)
mock.clear = Mock(side_effect=mock_clear)
mock.get_cached_data = Mock(side_effect=mock_get)
mock.save_cache = Mock(side_effect=mock_set)
mock.load_cache = Mock(side_effect=mock_get)
mock.get_cache_dir = Mock(return_value=mock.cache_dir)
return mock
@pytest.fixture
def mock_config_manager():
"""Create a mock ConfigManager for testing."""
mock = MagicMock()
mock.config = {}
mock.config_path = "config/config.json"
mock.secrets_path = "config/config_secrets.json"
mock.template_path = "config/config.template.json"
def mock_load_config() -> Dict[str, Any]:
return mock.config
def mock_get_config() -> Dict[str, Any]:
return mock.config
def mock_get_secret(key: str) -> Optional[Any]:
secrets = mock.config.get('_secrets', {})
return secrets.get(key)
mock.load_config = Mock(side_effect=mock_load_config)
mock.get_config = Mock(side_effect=mock_get_config)
mock.get_secret = Mock(side_effect=mock_get_secret)
mock.get_config_path = Mock(return_value=mock.config_path)
mock.get_secrets_path = Mock(return_value=mock.secrets_path)
return mock
@pytest.fixture
def mock_plugin_manager():
"""Create a mock PluginManager for testing."""
mock = MagicMock()
mock.plugins = {}
mock.plugin_manifests = {}
mock.get_plugin = Mock(return_value=None)
mock.load_plugin = Mock(return_value=True)
mock.unload_plugin = Mock(return_value=True)
return mock
@pytest.fixture
def test_config():
"""Provide a test configuration dictionary."""
return {
'display': {
'hardware': {
'rows': 32,
'cols': 64,
'chain_length': 2,
'parallel': 1,
'hardware_mapping': 'adafruit-hat-pwm',
'brightness': 90
},
'runtime': {
'gpio_slowdown': 2
}
},
'timezone': 'UTC',
'plugin_system': {
'plugins_directory': 'plugins'
}
}
@pytest.fixture
def test_cache_dir(tmp_path):
"""Provide a temporary cache directory for testing."""
cache_dir = tmp_path / "cache"
cache_dir.mkdir()
return str(cache_dir)
@pytest.fixture
def emulator_mode(monkeypatch):
"""Set emulator mode for testing."""
monkeypatch.setenv("EMULATOR", "true")
return True
@pytest.fixture(autouse=True)
def reset_logging():
"""Reset logging configuration before each test."""
import logging
logging.root.handlers = []
logging.root.setLevel(logging.WARNING)
yield
logging.root.handlers = []
logging.root.setLevel(logging.WARNING)
@pytest.fixture
def mock_plugin_instance(mock_display_manager, mock_cache_manager, mock_config_manager):
"""Create a mock plugin instance with all required methods."""
from unittest.mock import MagicMock
mock_plugin = MagicMock()
mock_plugin.plugin_id = "test_plugin"
mock_plugin.config = {"enabled": True, "display_duration": 30}
mock_plugin.display_manager = mock_display_manager
mock_plugin.cache_manager = mock_cache_manager
mock_plugin.plugin_manager = MagicMock()
mock_plugin.enabled = True
# Required methods
mock_plugin.update = MagicMock(return_value=None)
mock_plugin.display = MagicMock(return_value=True)
mock_plugin.get_display_duration = MagicMock(return_value=30.0)
# Optional methods
mock_plugin.supports_dynamic_duration = MagicMock(return_value=False)
mock_plugin.get_dynamic_duration_cap = MagicMock(return_value=None)
mock_plugin.is_cycle_complete = MagicMock(return_value=True)
mock_plugin.reset_cycle_state = MagicMock(return_value=None)
mock_plugin.has_live_priority = MagicMock(return_value=False)
mock_plugin.has_live_content = MagicMock(return_value=False)
mock_plugin.get_live_modes = MagicMock(return_value=[])
mock_plugin.on_config_change = MagicMock(return_value=None)
return mock_plugin
@pytest.fixture
def mock_plugin_with_live(mock_plugin_instance):
"""Create a mock plugin with live priority enabled."""
mock_plugin_instance.has_live_priority = MagicMock(return_value=True)
mock_plugin_instance.has_live_content = MagicMock(return_value=True)
mock_plugin_instance.get_live_modes = MagicMock(return_value=["test_plugin_live"])
mock_plugin_instance.config["live_priority"] = True
return mock_plugin_instance
@pytest.fixture
def mock_plugin_with_dynamic(mock_plugin_instance):
"""Create a mock plugin with dynamic duration enabled."""
mock_plugin_instance.supports_dynamic_duration = MagicMock(return_value=True)
mock_plugin_instance.get_dynamic_duration_cap = MagicMock(return_value=180.0)
mock_plugin_instance.is_cycle_complete = MagicMock(return_value=False)
mock_plugin_instance.reset_cycle_state = MagicMock(return_value=None)
mock_plugin_instance.config["dynamic_duration"] = {
"enabled": True,
"max_duration_seconds": 180
}
return mock_plugin_instance
@pytest.fixture
def test_config_with_plugins(test_config):
"""Provide a test configuration with multiple plugins enabled."""
config = test_config.copy()
config.update({
"plugin1": {
"enabled": True,
"display_duration": 30,
"update_interval": 300
},
"plugin2": {
"enabled": True,
"display_duration": 45,
"update_interval": 600,
"live_priority": True
},
"plugin3": {
"enabled": False,
"display_duration": 20
},
"display": {
**config.get("display", {}),
"display_durations": {
"plugin1": 30,
"plugin2": 45,
"plugin3": 20
},
"dynamic_duration": {
"max_duration_seconds": 180
}
}
})
return config
@pytest.fixture
def test_plugin_manager(mock_config_manager, mock_display_manager, mock_cache_manager):
"""Create a test PluginManager instance."""
from unittest.mock import patch, MagicMock
import tempfile
from pathlib import Path
# Create temporary plugin directory
with tempfile.TemporaryDirectory() as tmpdir:
plugin_dir = Path(tmpdir) / "plugins"
plugin_dir.mkdir()
with patch('src.plugin_system.plugin_manager.PluginManager') as MockPM:
pm = MagicMock()
pm.plugins = {}
pm.plugin_manifests = {}
pm.loaded_plugins = {}
pm.plugin_last_update = {}
pm.discover_plugins = MagicMock(return_value=[])
pm.load_plugin = MagicMock(return_value=True)
pm.unload_plugin = MagicMock(return_value=True)
pm.get_plugin = MagicMock(return_value=None)
pm.plugin_executor = MagicMock()
pm.health_tracker = None
pm.resource_monitor = None
MockPM.return_value = pm
yield pm
@pytest.fixture
def test_display_controller(mock_config_manager, mock_display_manager, mock_cache_manager,
test_config_with_plugins, emulator_mode):
"""Create a test DisplayController instance with mocked dependencies."""
from unittest.mock import patch, MagicMock
from src.display_controller import DisplayController
# Set up config manager to return test config
mock_config_manager.get_config.return_value = test_config_with_plugins
mock_config_manager.load_config.return_value = test_config_with_plugins
with patch('src.display_controller.ConfigManager', return_value=mock_config_manager), \
patch('src.display_controller.DisplayManager', return_value=mock_display_manager), \
patch('src.display_controller.CacheManager', return_value=mock_cache_manager), \
patch('src.display_controller.FontManager'), \
patch('src.plugin_system.PluginManager') as mock_pm_class:
# Set up plugin manager mock
mock_pm = MagicMock()
mock_pm.discover_plugins = MagicMock(return_value=[])
mock_pm.load_plugin = MagicMock(return_value=True)
mock_pm.get_plugin = MagicMock(return_value=None)
mock_pm.plugins = {}
mock_pm.loaded_plugins = {}
mock_pm.plugin_manifests = {}
mock_pm.plugin_last_update = {}
mock_pm.plugin_executor = MagicMock()
mock_pm.health_tracker = None
mock_pm_class.return_value = mock_pm
# Create controller
controller = DisplayController()
yield controller
# Cleanup
try:
controller.cleanup()
except Exception:
pass