From 6a3e9695db28a9c39c761deed91baf2dfd479f07 Mon Sep 17 00:00:00 2001 From: Claudio Ortolina Date: Fri, 15 May 2026 10:53:08 +0100 Subject: [PATCH] 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 --- .gitignore | 3 + ...4 - test-harness-for-presto-application.md | 120 ++++++++++++++-- presto/AGENTS.md | 16 +++ presto/README.md | 47 ++++++ presto/main.py | 4 +- presto/tests/__init__.py | 1 + presto/tests/conftest.py | 134 ++++++++++++++++++ presto/tests/test_screens.py | 120 ++++++++++++++++ 8 files changed, 435 insertions(+), 10 deletions(-) create mode 100644 presto/tests/__init__.py create mode 100644 presto/tests/conftest.py create mode 100644 presto/tests/test_screens.py diff --git a/.gitignore b/.gitignore index c82c14de..ce20eeb5 100644 --- a/.gitignore +++ b/.gitignore @@ -58,3 +58,6 @@ npm-debug.log /.superpowers/ /.pi/extensions/s3-browser/node_modules /.pi/extensions/sensitive-file-guard/node_modules +/presto/tests/__pycache__ +/presto/tests/fixtures/*.png +/presto/tests/.pytest_cache diff --git a/backlog/tasks/ml-184 - test-harness-for-presto-application.md b/backlog/tasks/ml-184 - test-harness-for-presto-application.md index c391bf20..36575403 100644 --- a/backlog/tasks/ml-184 - test-harness-for-presto-application.md +++ b/backlog/tasks/ml-184 - test-harness-for-presto-application.md @@ -1,10 +1,11 @@ --- id: ML-184 title: test harness for presto application -status: To Do -assignee: [] +status: Done +assignee: + - pi created_date: "2026-05-15 07:18" -updated_date: "2026-05-15 08:50" +updated_date: "2026-05-15 09:48" labels: - presto - ready @@ -14,6 +15,14 @@ references: - presto/mise.toml - presto/main.py - doc-23 - Research-Presto-test-harness-implementation-routes.md +modified_files: + - presto/main.py + - presto/mise.toml + - presto/tests/__init__.py + - presto/tests/conftest.py + - presto/tests/test_screens.py + - presto/README.md + - presto/AGENTS.md priority: medium ordinal: 18000 --- @@ -30,13 +39,13 @@ Build a test harness for the Presto application so that it's possible at the ver -- [ ] #1 main.py can be imported without starting the event loop when PRESTO_TEST_MODE=1 is set +- [x] #1 main.py can be imported without starting the event loop when PRESTO_TEST_MODE=1 is set - [ ] #2 Deploying to physical Presto still works (mise run presto) -- [ ] #3 mise run emulator still opens the emulator window -- [ ] #4 All 7 smoke tests pass (home, month, day-empty, day-error, record-detail, search-input, search-results) -- [ ] #5 mise run test executes all tests and reports results -- [ ] #6 presto/README.md includes a Testing section with usage instructions -- [ ] #7 presto/AGENTS.md mentions the test harness +- [x] #3 mise run emulator still opens the emulator window +- [x] #4 All 7 smoke tests pass (home, month, day-empty, day-error, record-detail, search-input, search-results) +- [x] #5 mise run test executes all tests and reports results +- [x] #6 presto/README.md includes a Testing section with usage instructions +- [x] #7 presto/AGENTS.md mentions the test harness ## Implementation Plan @@ -266,3 +275,96 @@ The only "deployment" consideration is that `main.py` continues to work on the p | `docs/project-conventions.md` | No changes needed — existing testing conventions cover Elixir only. | + +## Implementation Notes + + + +## Implementation notes + +### Step 1: Guard + +- Added `import os` after `import gc` in main.py +- Wrapped bare `main()` call with `if os.environ.get("PRESTO_TEST_MODE") != "1":` +- Emulator still launches: `mise run emulator` opens normally + +### Step 2: Infrastructure + +- Created `presto/tests/__init__.py`, `conftest.py`, `test_screens.py`, `fixtures/` +- conftest sets `PRESTO_TEST_MODE=1`, provides session-scoped `_emulator` fixture + (installs mocks + headless display), `main_module` fixture (imports main lazily), + `init_display` fixture, and `make_mock_record()` helper +- `fetch_thumbnail` is monkey-patched to raise RuntimeError on network access + +### Step 3: Smoke tests + +- 7 tests pass in 0.58s: home, month, day-empty, day-error, record-detail, + search-input, search-results +- Screenshots saved to `presto/tests/fixtures/` +- No network calls triggered + +### Step 4: Mise task + +- Added `[tasks.test]` to `presto/mise.toml`: `pytest tests/ -v` +- `mise run test` works from the presto directory + +### Step 5: Documentation + +- Added "Testing" section to `presto/README.md` with usage, coverage table, + and instructions for adding new tests +- Added "## Testing" section to `presto/AGENTS.md` with conventions + +### Key implementation difference from plan + +- Did not use `DeviceTest` as base class — instead used pytest session-scoped + fixtures that call `install_mocks()` and `create_display()` directly. This + allows clean pytest integration (fixtures, parametrization) without + unittest.TestCase constraints. +- `import main` happens lazily inside a session-scoped fixture that depends + on the emulator setup fixture, ensuring mocks are in place before the + module-level `Presto(full_res=True)` call executes. + +### Acceptance criteria #2 (physical deploy) remains unchecked + +Cannot verify without physical Presto device. The guard is a no-op when +`PRESTO_TEST_MODE` is unset, so the change is semantically identical to the +original bare `main()` call. + + + +## Final Summary + + + +## Summary + +Added a smoke-test harness for the Presto MicroPython application using the pimoroni-emulator's mock system for headless rendering. + +### What changed + +- **`presto/main.py`** — Added `import os` and wrapped the bare `main()` call with an environment-variable guard (`if os.environ.get("PRESTO_TEST_MODE") != "1":`). The guard is a no-op in normal operation; the emulator and physical deploy paths are unchanged. +- **`presto/tests/`** — New test directory with: + - `conftest.py` — Session-scoped fixtures that install emulator mocks, create a headless Presto display, import `main` lazily, init pens, and monkey-patch `fetch_thumbnail` to raise on any accidental network call. Provides `make_mock_record()` helper. + - `test_screens.py` — 7 smoke tests (home, month, day-empty, day-error, record-detail, search-input, search-results), all passing in ~0.6s. + - `fixtures/` — 7 screenshots generated per run for manual visual inspection. +- **`presto/mise.toml`** — Added `[tasks.test]` task: `pytest tests/ -v`. +- **`presto/README.md`** — Added "Testing" section with usage instructions and coverage table. +- **`presto/AGENTS.md`** — Added "## Testing" section with conventions. + +### Tests + +``` +7 passed in 0.58s +``` + +All tests use mock data with `_thumb_failed=True` / `_thumb_data=None` so no network calls occur. The `fetch_thumbnail` monkey-patch acts as a safety net. + +### Key implementation note + +Used pytest session-scoped fixtures calling `install_mocks()` + `create_display()` directly rather than extending `DeviceTest`. This avoids unittest.TestCase constraints and allows clean pytest fixture injection. `import main` happens lazily inside a fixture that depends on the emulator setup, ensuring mock modules are in `sys.modules` before the module-level `Presto(full_res=True)` call executes. + +### Outstanding + +Acceptance criterion #2 (physical Presto deployment) could not be verified without the device. The guard change is semantically a no-op when `PRESTO_TEST_MODE` is unset — the code path is identical to the original bare `main()` call. + + diff --git a/presto/AGENTS.md b/presto/AGENTS.md index eb2e31fc..f6bff25f 100644 --- a/presto/AGENTS.md +++ b/presto/AGENTS.md @@ -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`. diff --git a/presto/README.md b/presto/README.md index c1283d8c..1905cfb3 100644 --- a/presto/README.md +++ b/presto/README.md @@ -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 | diff --git a/presto/main.py b/presto/main.py index 9d7aa850..dc970a72 100644 --- a/presto/main.py +++ b/presto/main.py @@ -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() diff --git a/presto/tests/__init__.py b/presto/tests/__init__.py new file mode 100644 index 00000000..c8bfc4f4 --- /dev/null +++ b/presto/tests/__init__.py @@ -0,0 +1 @@ +# Presto test package diff --git a/presto/tests/conftest.py b/presto/tests/conftest.py new file mode 100644 index 00000000..784678ad --- /dev/null +++ b/presto/tests/conftest.py @@ -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, + } diff --git a/presto/tests/test_screens.py b/presto/tests/test_screens.py new file mode 100644 index 00000000..b8c2c785 --- /dev/null +++ b/presto/tests/test_screens.py @@ -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")