diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index aadebc4d..062e85b5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,6 +13,12 @@ jobs: plugin-safety: name: Plugin safety harness + unit tests runs-on: ubuntu-latest + env: + # The bundled fixture plugin gives the harness at least one real plugin + # to render, and REQUIRE_PLUGINS turns "discovered zero plugins" into a + # hard failure instead of a silent all-skip green run. + LEDMATRIX_PLUGINS_DIR: test/fixtures/plugins + LEDMATRIX_REQUIRE_PLUGINS: "1" steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: @@ -29,12 +35,9 @@ jobs: pip install -r requirements.txt -r requirements-test.txt pip install RGBMatrixEmulator - - name: Run harness + visual rendering tests + - name: Run plugin safety harness run: | - pytest --no-cov \ - test/plugins/test_harness.py \ - test/plugins/test_visual_rendering.py \ - test/plugins/test_plugin_matrix.py + pytest --no-cov test/plugins/ unit-tests: name: Core unit tests @@ -55,35 +58,15 @@ jobs: pip install -r requirements.txt -r requirements-test.txt pip install RGBMatrixEmulator - # Safety net for the shared sports/scroll/style infrastructure. These - # suites existed but were not enrolled in CI, so a refactor of - # src/base_classes or src/common could regress them silently. Enrolled - # explicitly (not `pytest test/`) so known hardware-only suites don't - # break CI; grow this list as more suites are made headless. + # Run the ENTIRE test tree (except test/plugins, which the + # plugin-safety job owns). New test files are enrolled automatically; + # excluding anything requires a visible, commented --ignore here. + # Coverage is measured and enforced only in this step — pytest.ini + # deliberately carries no coverage flags so local runs stay fast. - name: Run core unit suites run: | - pytest --no-cov \ - test/test_skin_system.py \ - test/test_font_manager.py \ - test/test_data_sources.py \ - test/test_api_extractors.py \ - test/test_scroll_helper.py \ - test/test_scroll_helper_continuous.py \ - test/test_adaptive_layout.py \ - test/test_loader_compat_warning.py \ - test/test_sports_base_characterization.py \ - test/test_element_style.py \ - test/test_sports_core_promotions.py \ - test/test_sports_modes_promotions.py \ - test/test_sports_capabilities.py \ - test/test_sports_scroll.py \ - test/test_version_consistency.py \ - test/test_plugin_compatibility_gate.py \ - test/test_install_preserves_existing.py \ - test/test_core_owned_config_keys.py \ - test/test_async_plugin_updates.py \ - test/test_plugin_update_reservation.py \ - test/test_template_targets.py \ - test/test_widget_scripts.py \ - test/test_doc_links.py \ - test/web_interface/test_cache.py + pytest -m "not hardware" test/ \ + --ignore=test/plugins \ + --cov=src --cov=web_interface \ + --cov-report=term \ + --cov-fail-under=45 diff --git a/pytest.ini b/pytest.ini index a13cc6cb..aaffa360 100644 --- a/pytest.ini +++ b/pytest.ini @@ -10,16 +10,13 @@ python_functions = test_* testpaths = test # Output options -# Note: Coverage options require pytest-cov to be installed -# Run: pip install pytest-cov -addopts = +# Coverage is deliberately NOT configured here: a bare local `pytest` should +# be fast and dependency-light. Coverage is measured and enforced in exactly +# one place — the unit-tests job in .github/workflows/test.yml. +addopts = -v --strict-markers --tb=short - --cov=src - --cov-report=term-missing - --cov-report=html - --cov-fail-under=30 # Markers markers = diff --git a/requirements-test.txt b/requirements-test.txt index 2faccf94..60e26951 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -4,4 +4,6 @@ pytest>=9.0.3,<10.0.0 pytest-cov>=4.1.0,<5.0.0 pytest-mock>=3.11.0,<4.0.0 freezegun>=1.2,<2 # deterministic time for golden-image tests +psutil>=6.0.0,<8.0.0 # optional at runtime; installed for tests so the + # /system/status endpoint's real path is exercised mypy>=1.5.0,<2.0.0 # static type checking (also pinned in .pre-commit-config.yaml) diff --git a/test/fixtures/plugins/ci-fixture-plugin/config_schema.json b/test/fixtures/plugins/ci-fixture-plugin/config_schema.json new file mode 100644 index 00000000..9410d90a --- /dev/null +++ b/test/fixtures/plugins/ci-fixture-plugin/config_schema.json @@ -0,0 +1,31 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "CI Fixture Plugin", + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "default": true + }, + "display_duration": { + "type": "number", + "default": 5 + }, + "border_color": { + "type": "array", + "items": {"type": "integer", "minimum": 0, "maximum": 255}, + "minItems": 3, + "maxItems": 3, + "default": [0, 255, 0], + "description": "RGB color of the border rectangle." + }, + "diagonal_color": { + "type": "array", + "items": {"type": "integer", "minimum": 0, "maximum": 255}, + "minItems": 3, + "maxItems": 3, + "default": [255, 0, 0], + "description": "RGB color of the diagonals." + } + } +} diff --git a/test/fixtures/plugins/ci-fixture-plugin/manager.py b/test/fixtures/plugins/ci-fixture-plugin/manager.py new file mode 100644 index 00000000..6e3d5d6e --- /dev/null +++ b/test/fixtures/plugins/ci-fixture-plugin/manager.py @@ -0,0 +1,38 @@ +""" +CI fixture plugin. + +Exists so the plugin safety harness (test/plugins/test_plugin_matrix.py and +the plugin-safety CI job) always has at least one real plugin to load and +render — without it, an empty plugins/ directory turns the whole job into a +green no-op. The render is deliberately trivial and fully deterministic: +a border rectangle plus both diagonals, sized from the display manager's +declared dimensions. No fonts, no network, no time dependence, so golden +images are stable across platforms. +""" + +from PIL import ImageDraw + +from src.plugin_system.base_plugin import BasePlugin + + +class CIFixturePlugin(BasePlugin): + def update(self) -> None: + """Nothing to fetch — the render is self-contained.""" + + def display(self, force_clear: bool = False) -> None: + width = self.display_manager.matrix.width + height = self.display_manager.matrix.height + border = tuple(self.config.get("border_color", [0, 255, 0])) + diagonal = tuple(self.config.get("diagonal_color", [255, 0, 0])) + + image = self.display_manager.image + draw = ImageDraw.Draw(image) + # Blank only the declared panel area, then draw edge-to-edge content: + # the border proves the plugin reads dynamic dimensions (any overflow + # or underfill at any size is a harness bug or a dimensions bug), the + # diagonals make golden comparisons sensitive to size/offset drift. + draw.rectangle([0, 0, width - 1, height - 1], fill=(0, 0, 0)) + draw.rectangle([0, 0, width - 1, height - 1], outline=border) + draw.line([0, 0, width - 1, height - 1], fill=diagonal) + draw.line([0, height - 1, width - 1, 0], fill=diagonal) + self.display_manager.update_display() diff --git a/test/fixtures/plugins/ci-fixture-plugin/manifest.json b/test/fixtures/plugins/ci-fixture-plugin/manifest.json new file mode 100644 index 00000000..40d188cb --- /dev/null +++ b/test/fixtures/plugins/ci-fixture-plugin/manifest.json @@ -0,0 +1,13 @@ +{ + "id": "ci-fixture-plugin", + "name": "CI Fixture Plugin", + "version": "1.0.0", + "description": "Bundled test fixture so the plugin safety harness always has at least one real plugin to render in CI. Draws a deterministic border + diagonals pattern at any panel size. Not installable from the store and never shipped to devices.", + "author": "LEDMatrix", + "entry_point": "manager.py", + "class_name": "CIFixturePlugin", + "display_modes": ["ci-fixture"], + "update_interval": 3600, + "min_ledmatrix_version": "2.0.0", + "compatible_versions": [">=2.0.0"] +} diff --git a/test/fixtures/plugins/ci-fixture-plugin/requirements.txt b/test/fixtures/plugins/ci-fixture-plugin/requirements.txt new file mode 100644 index 00000000..81444625 --- /dev/null +++ b/test/fixtures/plugins/ci-fixture-plugin/requirements.txt @@ -0,0 +1 @@ +# No dependencies — the fixture must load in any environment. diff --git a/test/fixtures/plugins/ci-fixture-plugin/test/golden/128x32/ci-fixture.png b/test/fixtures/plugins/ci-fixture-plugin/test/golden/128x32/ci-fixture.png new file mode 100644 index 00000000..32608a8c Binary files /dev/null and b/test/fixtures/plugins/ci-fixture-plugin/test/golden/128x32/ci-fixture.png differ diff --git a/test/fixtures/plugins/ci-fixture-plugin/test/golden/128x64/ci-fixture.png b/test/fixtures/plugins/ci-fixture-plugin/test/golden/128x64/ci-fixture.png new file mode 100644 index 00000000..de738059 Binary files /dev/null and b/test/fixtures/plugins/ci-fixture-plugin/test/golden/128x64/ci-fixture.png differ diff --git a/test/fixtures/plugins/ci-fixture-plugin/test/golden/128x96/ci-fixture.png b/test/fixtures/plugins/ci-fixture-plugin/test/golden/128x96/ci-fixture.png new file mode 100644 index 00000000..3107e6da Binary files /dev/null and b/test/fixtures/plugins/ci-fixture-plugin/test/golden/128x96/ci-fixture.png differ diff --git a/test/fixtures/plugins/ci-fixture-plugin/test/golden/256x128/ci-fixture.png b/test/fixtures/plugins/ci-fixture-plugin/test/golden/256x128/ci-fixture.png new file mode 100644 index 00000000..dd43ab76 Binary files /dev/null and b/test/fixtures/plugins/ci-fixture-plugin/test/golden/256x128/ci-fixture.png differ diff --git a/test/fixtures/plugins/ci-fixture-plugin/test/golden/256x32/ci-fixture.png b/test/fixtures/plugins/ci-fixture-plugin/test/golden/256x32/ci-fixture.png new file mode 100644 index 00000000..01638b37 Binary files /dev/null and b/test/fixtures/plugins/ci-fixture-plugin/test/golden/256x32/ci-fixture.png differ diff --git a/test/fixtures/plugins/ci-fixture-plugin/test/golden/64x32/ci-fixture.png b/test/fixtures/plugins/ci-fixture-plugin/test/golden/64x32/ci-fixture.png new file mode 100644 index 00000000..9312505b Binary files /dev/null and b/test/fixtures/plugins/ci-fixture-plugin/test/golden/64x32/ci-fixture.png differ diff --git a/test/fixtures/plugins/ci-fixture-plugin/test/golden/64x64/ci-fixture.png b/test/fixtures/plugins/ci-fixture-plugin/test/golden/64x64/ci-fixture.png new file mode 100644 index 00000000..7095ed8e Binary files /dev/null and b/test/fixtures/plugins/ci-fixture-plugin/test/golden/64x64/ci-fixture.png differ diff --git a/test/fixtures/plugins/ci-fixture-plugin/test/golden/96x48/ci-fixture.png b/test/fixtures/plugins/ci-fixture-plugin/test/golden/96x48/ci-fixture.png new file mode 100644 index 00000000..239561e4 Binary files /dev/null and b/test/fixtures/plugins/ci-fixture-plugin/test/golden/96x48/ci-fixture.png differ diff --git a/test/plugins/conftest.py b/test/plugins/conftest.py index 27bb6718..cff6dd42 100644 --- a/test/plugins/conftest.py +++ b/test/plugins/conftest.py @@ -23,9 +23,17 @@ os.environ['EMULATOR'] = 'true' def plugins_dir() -> Path: """Get the plugins directory path. - Checks plugins/ first, then falls back to plugin-repos/ - for monorepo development environments. + 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' diff --git a/test/plugins/test_basketball_scoreboard.py b/test/plugins/test_basketball_scoreboard.py index 0fbd8b79..b93fb89f 100644 --- a/test/plugins/test_basketball_scoreboard.py +++ b/test/plugins/test_basketball_scoreboard.py @@ -1,5 +1,10 @@ """ Integration tests for basketball-scoreboard plugin. + +Requires the real plugin to be installed (plugins/ or plugin-repos/, +or the dir named by LEDMATRIX_PLUGINS_DIR) — on machines without it, +every test here skips by design. CI covers plugin safety with the +bundled fixture plugin via test_plugin_matrix.py instead. """ import pytest diff --git a/test/plugins/test_calendar.py b/test/plugins/test_calendar.py index 18528fdb..4d874940 100644 --- a/test/plugins/test_calendar.py +++ b/test/plugins/test_calendar.py @@ -1,5 +1,10 @@ """ Integration tests for calendar plugin. + +Requires the real plugin to be installed (plugins/ or plugin-repos/, +or the dir named by LEDMATRIX_PLUGINS_DIR) — on machines without it, +every test here skips by design. CI covers plugin safety with the +bundled fixture plugin via test_plugin_matrix.py instead. """ import pytest diff --git a/test/plugins/test_clock_simple.py b/test/plugins/test_clock_simple.py index 507feec9..9b25a59e 100644 --- a/test/plugins/test_clock_simple.py +++ b/test/plugins/test_clock_simple.py @@ -1,5 +1,10 @@ """ Integration tests for clock-simple plugin. + +Requires the real plugin to be installed (plugins/ or plugin-repos/, +or the dir named by LEDMATRIX_PLUGINS_DIR) — on machines without it, +every test here skips by design. CI covers plugin safety with the +bundled fixture plugin via test_plugin_matrix.py instead. """ import pytest diff --git a/test/plugins/test_odds_ticker.py b/test/plugins/test_odds_ticker.py index 90209548..231ed7de 100644 --- a/test/plugins/test_odds_ticker.py +++ b/test/plugins/test_odds_ticker.py @@ -1,5 +1,10 @@ """ Integration tests for odds-ticker plugin. + +Requires the real plugin to be installed (plugins/ or plugin-repos/, +or the dir named by LEDMATRIX_PLUGINS_DIR) — on machines without it, +every test here skips by design. CI covers plugin safety with the +bundled fixture plugin via test_plugin_matrix.py instead. """ import pytest diff --git a/test/plugins/test_soccer_scoreboard.py b/test/plugins/test_soccer_scoreboard.py index 36212bfd..d5526fb8 100644 --- a/test/plugins/test_soccer_scoreboard.py +++ b/test/plugins/test_soccer_scoreboard.py @@ -1,5 +1,10 @@ """ Integration tests for soccer-scoreboard plugin. + +Requires the real plugin to be installed (plugins/ or plugin-repos/, +or the dir named by LEDMATRIX_PLUGINS_DIR) — on machines without it, +every test here skips by design. CI covers plugin safety with the +bundled fixture plugin via test_plugin_matrix.py instead. """ import pytest diff --git a/test/plugins/test_text_display.py b/test/plugins/test_text_display.py index a43815ea..34adfa49 100644 --- a/test/plugins/test_text_display.py +++ b/test/plugins/test_text_display.py @@ -1,5 +1,10 @@ """ Integration tests for text-display plugin. + +Requires the real plugin to be installed (plugins/ or plugin-repos/, +or the dir named by LEDMATRIX_PLUGINS_DIR) — on machines without it, +every test here skips by design. CI covers plugin safety with the +bundled fixture plugin via test_plugin_matrix.py instead. """ import pytest diff --git a/test/test_display_controller_vegas_tick.py b/test/test_display_controller_vegas_tick.py index 643359f3..538e9272 100644 --- a/test/test_display_controller_vegas_tick.py +++ b/test/test_display_controller_vegas_tick.py @@ -11,9 +11,18 @@ orphaning VegasModeCoordinator.mark_plugin_updated() -- it has had zero callers since. """ +import os from typing import Dict, List, Optional from unittest.mock import MagicMock +# display_controller imports display_manager, which imports the hardware +# rgbmatrix module unless EMULATOR=true was set before import. Use the +# emulator (same convention as test_display_dirty_tracking.py and +# test/plugins/conftest.py) so this file collects on machines without the +# hardware library — and so display_manager gets the emulator binding no +# matter which test module imports it first. +os.environ.setdefault("EMULATOR", "true") + from src.display_controller import DisplayController diff --git a/test/test_web_api.py b/test/test_web_api.py index 418bb68c..8cadf922 100644 --- a/test/test_web_api.py +++ b/test/test_web_api.py @@ -393,6 +393,9 @@ class TestSystemAPI: @patch('web_interface.blueprints.api_v3.subprocess') def test_get_system_status(self, mock_subprocess, client): """Test getting system status.""" + # The endpoint returns 503 without psutil, which is an optional + # runtime dependency (requirements-test.txt installs it for CI). + pytest.importorskip("psutil") mock_result = MagicMock() mock_result.stdout = 'active\n' mock_result.returncode = 0 diff --git a/test/web_interface/test_state_reconciliation.py b/test/web_interface/test_state_reconciliation.py index 19f23833..7d01b886 100644 --- a/test/web_interface/test_state_reconciliation.py +++ b/test/web_interface/test_state_reconciliation.py @@ -367,6 +367,10 @@ class TestStateReconciliationUnrecoverable(unittest.TestCase): self.store_manager.fetch_registry.return_value = {"plugins": []} self.store_manager.install_plugin.return_value = False self.store_manager.was_recently_uninstalled.return_value = False + # A bare Mock() returns a truthy Mock for is_plugin_uninstalled(), + # which reads as "persistently uninstalled" and skips auto-repair + # entirely — these tests need the repair path to run. + self.store_manager.is_plugin_uninstalled.return_value = False self.reconciler = StateReconciliation( state_manager=self.state_manager,