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:
Claudio Ortolina
2026-05-15 10:53:08 +01:00
parent 33985cd742
commit 6a3e9695db
8 changed files with 435 additions and 10 deletions
+16
View File
@@ -31,6 +31,22 @@ python3 -c "import py_compile; py_compile.compile('main.py', cfile='/tmp/main.py
Do not claim device behavior is verified unless it was tested on the physical Presto.
## Testing
The test harness lives in `presto/tests/` and uses the `pimoroni-emulator`'s
mock MicroPython modules for headless smoke testing.
- Run tests: `mise run test`
- Tests import `main.py` without starting the event loop (guarded by
`PRESTO_TEST_MODE=1`).
- Smoke tests cover every screen state. When adding a new screen, add a
corresponding test in `presto/tests/test_screens.py`.
- All tests use mock data from `make_mock_record()`. No network calls are
made during test execution — `fetch_thumbnail` is monkey-patched to raise
on any accidental network access.
- Screenshots are saved to `presto/tests/fixtures/` for manual visual
inspection.
## Hardware And Runtime Constraints
- Stock Presto firmware: MicroPython, PicoGraphics, touch driver, `urequests`, `ntptime`, usually `jpegdec`.
+47
View File
@@ -130,6 +130,53 @@ presto/
README.md # This file
```
## Testing
### Prerequisites
The test harness uses the [`pimoroni-emulator`](https://github.com/iksaif/pimoroni-emu)
(already installed via `mise`) to provide a headless display with mock
MicroPython modules. You also need `pytest` (installed via the project venv).
### Running tests
```bash
# From the presto/ directory:
mise run test
# Or directly:
cd presto
pytest tests/ -v
```
### What the tests cover
Smoke tests verify that every screen renders without crashing:
| Test | Screen |
| ------------------------ | ---------------------- |
| `test_home_screen` | Home / splash |
| `test_month_view` | Month calendar grid |
| `test_day_view_empty` | Day view (no records) |
| `test_day_view_error` | Day view (error state) |
| `test_record_detail` | Record detail view |
| `test_search_input` | Search input screen |
| `test_search_results` | Search results list |
All tests use mock data and do **not** make network requests.
Screenshots are saved to `presto/tests/fixtures/` for manual inspection.
### Adding new tests
1. Add a test method to `presto/tests/test_screens.py` following the existing
pattern:
- Create an `AppState` via `main_module.AppState()`
- Set the appropriate view state fields
- Call the screen's draw function
- Optionally save a screenshot
2. Use `make_mock_record()` from `tests.conftest` if your test needs record data.
3. Run `mise run test` to verify.
## Troubleshooting
| Symptom | Likely cause | Fix |
+3 -1
View File
@@ -29,6 +29,7 @@ Setup:
# ============================================================================
import gc
import os
import time
import network
import ntptime
@@ -2713,4 +2714,5 @@ def main():
# STARTUP
# ============================================================================
main()
if os.environ.get("PRESTO_TEST_MODE") != "1":
main()
+1
View File
@@ -0,0 +1 @@
# Presto test package
+134
View File
@@ -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,
}
+120
View File
@@ -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")