mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-01 08:48:05 +00:00
Add src/adaptive_layout.py — opt-in core helpers so plugins render legibly on any panel size without hand-tuned per-display layouts: - Region: integer rect algebra (bands/columns/weighted splits/centering) that partitions space so text bands can't overlap by construction - Font ladders: ordered (family, size) steps known to render crisply (LADDER_GRID: X11 BDFs at native sizes; LADDER_ARCADE: PressStart2P at 8px multiples) — fitting walks the ladder instead of scaling pixel fonts fractionally - LayoutContext: breakpoint tiers, geometry scale vs. a declared design size, and cached fit_text/fit_lines/font_for_rows queries Generalizes the three patterns proven in the field: f1-scoreboard's scale factor, masters-tournament's tiers, baseball-scoreboard's font fallback ladder. Wiring: BasePlugin gains a lazy .layout property and draw_fit(); FontManager gains get_native_bdf_size() and a cache_generation counter; manifest schema gains display.design_size and requires.display_size max_width/max_height; 96x48 joins DEFAULT_TEST_SIZES; the bounds-check harness records negative-coordinate draws; TextHelper's broken measurement helpers are fixed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
200 lines
6.3 KiB
Python
200 lines
6.3 KiB
Python
"""
|
|
Mock objects for plugin testing.
|
|
|
|
Provides mock implementations of display_manager, cache_manager, config_manager,
|
|
and plugin_manager for use in plugin unit tests.
|
|
"""
|
|
|
|
from typing import Dict, Any, Optional
|
|
from PIL import Image
|
|
|
|
|
|
class MockDisplayManager:
|
|
"""Mock display manager for testing."""
|
|
|
|
def __init__(self, width: int = 128, height: int = 32):
|
|
self.width = width
|
|
self.display_width = width
|
|
self.height = height
|
|
self.display_height = height
|
|
self.image = Image.new('RGB', (width, height), color=(0, 0, 0))
|
|
self.clear_called = False
|
|
self.update_called = False
|
|
self.draw_calls = []
|
|
|
|
def clear(self):
|
|
"""Clear the display."""
|
|
self.clear_called = True
|
|
self.image = Image.new('RGB', (self.width, self.height), color=(0, 0, 0))
|
|
|
|
def update_display(self):
|
|
"""Update the display."""
|
|
self.update_called = True
|
|
|
|
def draw_text(self, text: str, x: int, y: int, color: tuple = (255, 255, 255), font=None):
|
|
"""Draw text on the display."""
|
|
self.draw_calls.append({
|
|
'type': 'text',
|
|
'text': text,
|
|
'x': x,
|
|
'y': y,
|
|
'color': color,
|
|
'font': font
|
|
})
|
|
|
|
def draw_image(self, image: Image.Image, x: int, y: int):
|
|
"""Draw an image on the display."""
|
|
self.draw_calls.append({
|
|
'type': 'image',
|
|
'image': image,
|
|
'x': x,
|
|
'y': y
|
|
})
|
|
|
|
def reset(self):
|
|
"""Reset mock state."""
|
|
self.clear_called = False
|
|
self.update_called = False
|
|
self.draw_calls = []
|
|
self.image = Image.new('RGB', (self.width, self.height), color=(0, 0, 0))
|
|
|
|
|
|
class MockCacheManager:
|
|
"""Mock cache manager for testing."""
|
|
|
|
def __init__(self):
|
|
import shutil
|
|
import tempfile
|
|
import weakref
|
|
self._cache: Dict[str, Any] = {}
|
|
self._cache_timestamps: Dict[str, float] = {}
|
|
self.get_calls = []
|
|
self.set_calls = []
|
|
self.delete_calls = []
|
|
# Real temp dir for plugins that write/read files under cache_dir.
|
|
# Registered for cleanup so each mock instance doesn't leak a tmp dir.
|
|
self.cache_dir = tempfile.mkdtemp(prefix="ledmatrix-mock-cache-")
|
|
self._finalizer = weakref.finalize(
|
|
self, shutil.rmtree, self.cache_dir, ignore_errors=True)
|
|
|
|
def cleanup(self) -> None:
|
|
"""Remove the temp cache directory created for this instance."""
|
|
self._finalizer()
|
|
|
|
def get(self, key: str, max_age: Optional[float] = None) -> Optional[Any]:
|
|
"""Get a value from cache."""
|
|
import time
|
|
self.get_calls.append({'key': key, 'max_age': max_age})
|
|
|
|
if key not in self._cache:
|
|
return None
|
|
|
|
if max_age is not None:
|
|
timestamp = self._cache_timestamps.get(key, 0)
|
|
if time.time() - timestamp > max_age:
|
|
return None
|
|
|
|
return self._cache.get(key)
|
|
|
|
def set(self, key: str, value: Any, ttl: Optional[float] = None) -> None:
|
|
"""Set a value in cache."""
|
|
import time
|
|
self.set_calls.append({'key': key, 'value': value, 'ttl': ttl})
|
|
self._cache[key] = value
|
|
self._cache_timestamps[key] = time.time()
|
|
|
|
def delete(self, key: str) -> None:
|
|
"""Delete a value from cache."""
|
|
self.delete_calls.append(key)
|
|
if key in self._cache:
|
|
del self._cache[key]
|
|
if key in self._cache_timestamps:
|
|
del self._cache_timestamps[key]
|
|
|
|
def reset(self):
|
|
"""Reset mock state."""
|
|
self._cache.clear()
|
|
self._cache_timestamps.clear()
|
|
self.get_calls = []
|
|
self.set_calls = []
|
|
self.delete_calls = []
|
|
|
|
|
|
class MockConfigManager:
|
|
"""Mock config manager for testing."""
|
|
|
|
def __init__(self, config: Optional[Dict[str, Any]] = None):
|
|
self._config = config or {}
|
|
self.load_config_calls = []
|
|
self.save_config_calls = []
|
|
|
|
def load_config(self) -> Dict[str, Any]:
|
|
"""Load configuration."""
|
|
self.load_config_calls.append({})
|
|
return self._config.copy()
|
|
|
|
def save_config(self, config: Dict[str, Any]) -> None:
|
|
"""Save configuration."""
|
|
self.save_config_calls.append(config)
|
|
self._config = config.copy()
|
|
|
|
def get_config(self, key: str, default: Any = None) -> Any:
|
|
"""Get a config value."""
|
|
return self._config.get(key, default)
|
|
|
|
def set_config(self, key: str, value: Any) -> None:
|
|
"""Set a config value."""
|
|
self._config[key] = value
|
|
|
|
def reset(self):
|
|
"""Reset mock state."""
|
|
self._config = {}
|
|
self.load_config_calls = []
|
|
self.save_config_calls = []
|
|
|
|
|
|
class MockPluginManager:
|
|
"""Mock plugin manager for testing."""
|
|
|
|
def __init__(self):
|
|
self.plugins: Dict[str, Any] = {}
|
|
self.plugin_manifests: Dict[str, Dict] = {}
|
|
self.get_plugin_calls = []
|
|
self.get_all_plugins_calls = []
|
|
# Real FontManager so BasePlugin.layout / draw_fit behave identically
|
|
# under the harness (it only needs assets/fonts on disk).
|
|
try:
|
|
from src.font_manager import FontManager
|
|
self.font_manager: Optional[Any] = FontManager({})
|
|
except Exception:
|
|
self.font_manager = None
|
|
|
|
def get_plugin(self, plugin_id: str) -> Optional[Any]:
|
|
"""Get a plugin instance."""
|
|
self.get_plugin_calls.append(plugin_id)
|
|
return self.plugins.get(plugin_id)
|
|
|
|
def get_all_plugins(self) -> Dict[str, Any]:
|
|
"""Get all plugin instances."""
|
|
self.get_all_plugins_calls.append({})
|
|
return self.plugins.copy()
|
|
|
|
def get_plugin_info(self, plugin_id: str) -> Optional[Dict[str, Any]]:
|
|
"""Get plugin information."""
|
|
manifest = self.plugin_manifests.get(plugin_id, {})
|
|
plugin = self.plugins.get(plugin_id)
|
|
if plugin:
|
|
manifest['loaded'] = True
|
|
manifest['runtime_info'] = getattr(plugin, 'get_info', lambda: {})()
|
|
else:
|
|
manifest['loaded'] = False
|
|
return manifest
|
|
|
|
def reset(self):
|
|
"""Reset mock state."""
|
|
self.plugins.clear()
|
|
self.plugin_manifests.clear()
|
|
self.get_plugin_calls = []
|
|
self.get_all_plugins_calls = []
|
|
|