ML-184: add Presto smoke test harness
Add a headless smoke-test harness for the Presto microPython app using pimoroni-emulator mock modules. A PRESTO_TEST_MODE=1 guard prevents main() from running at import time so draw functions can be called directly from tests. - presto/main.py: wrap main() with PRESTO_TEST_MODE guard - presto/tests/: conftest with emulator fixtures, 7 smoke tests - presto/mise.toml: add [tasks.test] - presto/README.md, presto/AGENTS.md: add Testing section - .gitignore: exclude test __pycache__, pytest_cache, fixture pngs
This commit is contained in:
@@ -0,0 +1 @@
|
||||
# Presto test package
|
||||
@@ -0,0 +1,134 @@
|
||||
"""Presto smoke test configuration and helpers.
|
||||
|
||||
Uses the pimoroni-emulator's mock system so ``main.py`` can be imported
|
||||
on CPython without real MicroPython hardware. The emulator's headless
|
||||
display is set up as a session-scoped fixture; ``main`` is imported once
|
||||
after the mocks are in place.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# Ensure the presto directory is on sys.path so `import main` works.
|
||||
_presto_dir = Path(__file__).resolve().parent.parent
|
||||
if str(_presto_dir) not in sys.path:
|
||||
sys.path.insert(0, str(_presto_dir))
|
||||
|
||||
# Prevent main() from running when we import the module.
|
||||
os.environ["PRESTO_TEST_MODE"] = "1"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Emulator session setup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def _emulator():
|
||||
"""Install mock MicroPython modules and create a headless Presto display.
|
||||
|
||||
This must run before ``main`` is imported because ``main.py`` imports
|
||||
``presto``, ``network``, ``picographics`` etc. at module level.
|
||||
"""
|
||||
from emulator import _emulator_state
|
||||
from emulator.devices import get_device
|
||||
from emulator.display import create_display
|
||||
from emulator.mocks import install_mocks
|
||||
|
||||
device = get_device("presto")
|
||||
_emulator_state["device"] = device
|
||||
_emulator_state["running"] = True
|
||||
_emulator_state["headless"] = True
|
||||
|
||||
install_mocks()
|
||||
|
||||
display = create_display(device, headless=True)
|
||||
_emulator_state["display"] = display
|
||||
display.init()
|
||||
|
||||
yield
|
||||
|
||||
_emulator_state["running"] = False
|
||||
display.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module fixture — import main once
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def main_module(_emulator):
|
||||
"""Import ``main.py`` after the emulator mocks are installed.
|
||||
|
||||
The ``PRESTO_TEST_MODE=1`` guard ensures ``main()`` does not run.
|
||||
``fetch_thumbnail`` is monkey-patched to raise on any network call.
|
||||
"""
|
||||
import main
|
||||
|
||||
# Guard against accidental network access in smoke tests.
|
||||
def _raise_on_network(*_args, **_kwargs):
|
||||
raise RuntimeError("Network call in smoke test — use mock data")
|
||||
|
||||
main.fetch_thumbnail = _raise_on_network
|
||||
|
||||
return main
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-class display initialisation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture(scope="class")
|
||||
def init_display(main_module):
|
||||
"""Call ``init_display()`` once per test class so pens are ready."""
|
||||
main_module.init_display()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mock record helper (plain function — no main import needed)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def make_mock_record(index=0):
|
||||
"""Return a fully-populated mock record dict for smoke tests.
|
||||
|
||||
Every key required by draw functions is pre-populated. Thumbnail
|
||||
cache entries are set to ``None`` / ``True`` so the renderer uses
|
||||
grey placeholders and never calls ``fetch_thumbnail``.
|
||||
"""
|
||||
title = f"Mock Album {index}"
|
||||
artist = f"Mock Artist {index}"
|
||||
return {
|
||||
# API-level fields
|
||||
"id": f"rec-{index}",
|
||||
"selected_release_id": f"release-uuid-{index}",
|
||||
"title": title,
|
||||
"artists": [artist],
|
||||
"format": "Vinyl",
|
||||
"release_date": "1959-08-17",
|
||||
"genres": ["Jazz", "Modal"],
|
||||
"record_type": "LP",
|
||||
"purchased_at": "2024-03-15",
|
||||
# Pre-computed display strings (row view)
|
||||
"_display_title": title,
|
||||
"_display_artists": artist,
|
||||
"_display_meta": "Vinyl | 1959",
|
||||
"_display_title_lines": [(title, 120)],
|
||||
"_display_artist_lines": [(artist, 120)],
|
||||
"_display_meta_lines": [("Vinyl | 1959", 120)],
|
||||
# Thumbnail cache (row view) – force placeholder
|
||||
"_thumb_url": "",
|
||||
"_thumb_data": None,
|
||||
"_thumb_failed": True,
|
||||
# Detail-view cache keys
|
||||
"_detail_thumb_data": None,
|
||||
"_detail_thumb_failed": True,
|
||||
"_detail_title_lines": [(title, 22)],
|
||||
"_detail_artist_lines": [(artist, 15)],
|
||||
"_detail_genre_lines": [("Jazz, Modal", 15)],
|
||||
"_detail_meta_lines": [("LP | Vinyl | 1959", 15)],
|
||||
"_detail_purchased_lines": [("Purchased: 2024-03-15", 15)],
|
||||
# Row height (computed by prepare_record_list)
|
||||
"_row_height": 88,
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Smoke tests — verify every Presto screen renders without crashing.
|
||||
|
||||
Each test:
|
||||
1. Creates an ``AppState`` (requires ``init_display`` fixture).
|
||||
2. Sets the appropriate view state fields.
|
||||
3. Calls the screen's draw function.
|
||||
4. Asserts no exception occurred.
|
||||
5. Saves a screenshot to ``presto/tests/fixtures/`` for manual inspection.
|
||||
|
||||
No network calls are made — ``fetch_thumbnail`` is monkey-patched to raise,
|
||||
and mock records have ``_thumb_failed=True`` / ``_thumb_data=None``.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.conftest import make_mock_record
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("init_display")
|
||||
class TestSmokeScreens:
|
||||
"""Smoke tests for all 7 Presto screens."""
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _make_app(main_module):
|
||||
"""Create a fresh ``AppState``."""
|
||||
return main_module.AppState()
|
||||
|
||||
@staticmethod
|
||||
def _screenshot(main_module, name):
|
||||
"""Save the current emulator framebuffer to fixtures/."""
|
||||
from emulator.testing import screenshot
|
||||
|
||||
path = f"tests/fixtures/{name}.png"
|
||||
screenshot(path)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Tests
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def test_home_screen_renders(self, main_module):
|
||||
"""Home / splash screen."""
|
||||
app = self._make_app(main_module)
|
||||
main_module.draw_home_screen(app)
|
||||
self._screenshot(main_module, "home")
|
||||
|
||||
def test_month_view_renders(self, main_module):
|
||||
"""Month calendar grid."""
|
||||
app = self._make_app(main_module)
|
||||
app.screen = main_module.STATE_MONTH
|
||||
app.view_year = 2026
|
||||
app.view_month = 5
|
||||
main_module.draw_month_view(app)
|
||||
self._screenshot(main_module, "month")
|
||||
|
||||
def test_day_view_empty_renders(self, main_module):
|
||||
"""Day view with no records."""
|
||||
app = self._make_app(main_module)
|
||||
app.screen = main_module.STATE_DAY
|
||||
app.view_year = 2026
|
||||
app.view_month = 5
|
||||
app.day.selected_day = 15
|
||||
app.day.records = []
|
||||
app.day.error = False
|
||||
main_module.draw_day_view(app)
|
||||
self._screenshot(main_module, "day-empty")
|
||||
|
||||
def test_day_view_error_renders(self, main_module):
|
||||
"""Day view with error state."""
|
||||
app = self._make_app(main_module)
|
||||
app.screen = main_module.STATE_DAY
|
||||
app.view_year = 2026
|
||||
app.view_month = 5
|
||||
app.day.selected_day = 15
|
||||
app.day.records = []
|
||||
app.day.error = True
|
||||
main_module.draw_day_view(app)
|
||||
self._screenshot(main_module, "day-error")
|
||||
|
||||
def test_record_detail_renders(self, main_module):
|
||||
"""Record detail view."""
|
||||
app = self._make_app(main_module)
|
||||
app.screen = main_module.STATE_RECORD
|
||||
app.view_year = 2026
|
||||
app.view_month = 5
|
||||
app.day.selected_day = 15
|
||||
|
||||
rec = make_mock_record(0)
|
||||
app.day.records = [rec]
|
||||
app.detail.selected_index = 0
|
||||
app.detail.source_screen = main_module.STATE_DAY
|
||||
|
||||
main_module.draw_record_detail(app)
|
||||
self._screenshot(main_module, "record-detail")
|
||||
|
||||
def test_search_input_renders(self, main_module):
|
||||
"""Search input screen with on-screen keyboard."""
|
||||
app = self._make_app(main_module)
|
||||
app.screen = main_module.STATE_SEARCH_INPUT
|
||||
main_module.draw_search_input(app)
|
||||
self._screenshot(main_module, "search-input")
|
||||
|
||||
def test_search_results_renders(self, main_module):
|
||||
"""Search results list."""
|
||||
app = self._make_app(main_module)
|
||||
app.screen = main_module.STATE_SEARCH_RESULTS
|
||||
|
||||
# Populate results with mock records
|
||||
recs = [make_mock_record(i) for i in range(3)]
|
||||
app.search.results = recs
|
||||
|
||||
# Pre-compute content height (prepare_record_list won't trigger
|
||||
# network calls because _thumb_failed=True and _thumb_url="").
|
||||
app.search.content_height = main_module.prepare_record_list(app, recs)
|
||||
|
||||
main_module.draw_search_results(app)
|
||||
self._screenshot(main_module, "search-results")
|
||||
Reference in New Issue
Block a user