Files
LEDMatrix/test/plugins/conftest.py
T
Claude 911c559fd4 ci: run the whole test tree and make the plugin-safety job assert something real
The unit-tests CI job ran an explicit 24-file allowlist that had rotted:
63 of 90 test files (display, vegas, store manager, web API, web_interface)
never ran on a PR. The job now runs all of test/ (minus test/plugins, which
the plugin-safety job owns) so new test files are enrolled by default and
any exclusion needs a visible, commented --ignore.

The plugin-safety job was a green no-op: plugins/ is empty in CI, so every
test skipped with 'Manifest not found'. It now renders a bundled
deterministic fixture plugin (test/fixtures/plugins/ci-fixture-plugin,
golden images included for all 8 default sizes) via LEDMATRIX_PLUGINS_DIR,
and sets LEDMATRIX_REQUIRE_PLUGINS=1 so discovering zero plugins fails
loudly instead of skipping green. The per-plugin suites document that they
target dev machines with real plugins installed.

Coverage is now measured and enforced in exactly one place — the CI
unit-tests step (--cov=src --cov=web_interface --cov-fail-under=45, from a
measured 47% baseline). pytest.ini previously declared --cov-fail-under=30
but CI always passed --no-cov, so the gate had never run anywhere; local
pytest is now coverage-free and fast.

Enabling the 63 unenrolled files surfaced three cases of test rot, fixed
here: test_display_controller_vegas_tick.py could not collect without the
hardware rgbmatrix module (now uses the emulator convention), the
state-reconciliation unrecoverable-cache tests broke when production added
the is_plugin_uninstalled tombstone check (bare Mock returned truthy),
and test_get_system_status assumed the optional psutil dependency
(now installed via requirements-test.txt and guarded by importorskip).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NohXi78cwsAKtN1sCfxjUh
2026-08-06 22:30:21 +00:00

140 lines
3.9 KiB
Python

"""
Pytest fixtures for plugin integration tests.
"""
import pytest
import os
import sys
import json
from pathlib import Path
from unittest.mock import MagicMock, Mock
from typing import Any, Dict
# Add project root to path
project_root = Path(__file__).parent.parent.parent
if str(project_root) not in sys.path:
sys.path.insert(0, str(project_root))
# Set emulator mode
os.environ['EMULATOR'] = 'true'
@pytest.fixture
def plugins_dir() -> Path:
"""Get the plugins directory path.
Honors LEDMATRIX_PLUGINS_DIR (first entry) when set — the same override
test_plugin_matrix.py uses, so CI can point every plugin suite at the
bundled fixture plugins. Otherwise checks plugins/ first, then falls
back to plugin-repos/ for monorepo development environments.
"""
env = os.environ.get('LEDMATRIX_PLUGINS_DIR')
if env:
first = env.split(os.pathsep)[0]
if first:
return Path(first)
plugins_path = project_root / 'plugins'
plugin_repos_path = project_root / 'plugin-repos'
# Prefer plugins/ if it has actual plugin directories
if plugins_path.exists():
try:
has_plugins = any(
p for p in plugins_path.iterdir()
if p.is_dir() and not p.name.startswith('.')
)
if has_plugins:
return plugins_path
except PermissionError:
pass
if plugin_repos_path.exists():
return plugin_repos_path
return plugins_path
@pytest.fixture
def mock_display_manager() -> Any:
"""Create a mock DisplayManager for plugin tests."""
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)
# Some plugins access matrix.width/height
mock.matrix = MagicMock()
mock.matrix.width = 128
mock.matrix.height = 32
return mock
@pytest.fixture
def mock_cache_manager() -> Any:
"""Create a mock CacheManager for plugin tests."""
mock = MagicMock()
mock._memory_cache = {}
def mock_get(key: str, max_age: int = 300) -> Any:
return mock._memory_cache.get(key)
def mock_set(key: str, data: Any, ttl: int = None) -> None:
mock._memory_cache[key] = data
def mock_clear(key: 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)
return mock
@pytest.fixture
def mock_plugin_manager() -> Any:
"""Create a mock PluginManager for plugin tests."""
mock = MagicMock()
mock.plugins = {}
mock.plugin_manifests = {}
return mock
@pytest.fixture
def base_plugin_config() -> Dict[str, Any]:
"""Base configuration for plugins."""
return {
'enabled': True,
'update_interval': 300
}
def load_plugin_manifest(plugin_id: str, plugins_dir: Path) -> Dict[str, Any]:
"""Load plugin manifest.json."""
manifest_path = plugins_dir / plugin_id / 'manifest.json'
if not manifest_path.exists():
pytest.skip(f"Manifest not found for {plugin_id}")
with open(manifest_path, 'r') as f:
return json.load(f)
def get_plugin_config_schema(plugin_id: str, plugins_dir: Path) -> Dict[str, Any]:
"""Load plugin config_schema.json if it exists."""
schema_path = plugins_dir / plugin_id / 'config_schema.json'
if schema_path.exists():
with open(schema_path, 'r') as f:
return json.load(f)
return None
@pytest.fixture
def visual_display_manager() -> Any:
"""Create a VisualTestDisplayManager that renders real pixels for visual testing."""
from src.plugin_system.testing import VisualTestDisplayManager
return VisualTestDisplayManager(width=128, height=32)