ML-197: add Elm-style Presto application

This commit is contained in:
Claudio Ortolina
2026-05-26 08:59:01 +03:00
parent 631053b87e
commit 19e097e514
7 changed files with 3128 additions and 32 deletions
@@ -1,11 +1,11 @@
--- ---
id: ML-197 id: ML-197
title: Provide Elm-style Presto Music Library application title: Provide Elm-style Presto Music Library application
status: In Progress status: Done
assignee: assignee:
- Codex - Codex
created_date: "2026-05-26 05:39" created_date: "2026-05-26 05:39"
updated_date: "2026-05-26 05:43" updated_date: "2026-05-26 05:56"
labels: labels:
- presto - presto
dependencies: [] dependencies: []
@@ -16,6 +16,13 @@ documentation:
- presto/main.py - presto/main.py
- presto/tests/test_screens.py - presto/tests/test_screens.py
- presto/mise.toml - presto/mise.toml
modified_files:
- presto/music_library.py
- presto/tests/conftest.py
- presto/tests/test_screens.py
- presto/tests/test_music_library_architecture.py
- presto/mise.toml
- presto/README.md
priority: medium priority: medium
ordinal: 30000 ordinal: 30000
--- ---
@@ -32,10 +39,10 @@ Add an alternative Presto Music Library application organized around the message
<!-- AC:BEGIN --> <!-- AC:BEGIN -->
- [ ] #1 A `music_library.py` application exposes the existing Home, calendar/day browsing, search, record detail, scrobble, network error, and display sleep interactions through the Elm-style runtime. - [x] #1 A `music_library.py` application exposes the existing Home, calendar/day browsing, search, record detail, scrobble, network error, and display sleep interactions through the Elm-style runtime.
- [ ] #2 Scrolling, cover loading, wake handling, and API request behavior remain consistent with the current Presto app constraints. - [x] #2 Scrolling, cover loading, wake handling, and API request behavior remain consistent with the current Presto app constraints.
- [ ] #3 Headless verification covers the new application screens and prevents accidental network access during render tests. - [x] #3 Headless verification covers the new application screens and prevents accidental network access during render tests.
- [ ] #4 User-facing or deployment documentation is updated if introducing the alternative application changes available usage or verification instructions. - [x] #4 User-facing or deployment documentation is updated if introducing the alternative application changes available usage or verification instructions.
<!-- AC:END --> <!-- AC:END -->
## Implementation Plan ## Implementation Plan
@@ -60,4 +67,32 @@ Inspected `presto/poc.py`, `presto/main.py`, test harness, and `presto/mise.toml
User approved the implementation plan on 2026-05-26 and requested that this task record be committed before application work begins. User approved the implementation plan on 2026-05-26 and requested that this task record be committed before application work begins.
Implemented the standalone architecture-based entrypoint with `Model`/`Runtime`, message-driven transitions, command dispatch/effects, centralized rendering, prepared detail-cover effects, nonblocking drag messages, display sleep/wake consumption, API loading/reconnect effects, and scrobble feedback effects.
Extended the emulator screen contract suite to cover both `main.py` and `music_library.py`, and added focused tests for the status state, render-without-cover-fetch, detail preparation messages, wake consumption, WiFi reconnect before search, and throttled drag updates.
Added optional deployment documentation and a `music_library` mise deploy task. A compatible concurrent change switching the emulator task target to `music_library.py` was present in `presto/mise.toml`; it is not part of the agent-authored deploy-task hunk and will be left unstaged.
Verified `main.py` and `music_library.py` with `py_compile` output directed to `/tmp`; verified the headless suite with `mise run test` (36 passed). Physical Presto hardware was not tested.
<!-- SECTION:NOTES:END --> <!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
## Summary
- Added `presto/music_library.py`, a standalone Music Library entrypoint organized around the Elm-style `Model` / `Runtime` / message / effect / render architecture while preserving the current UI and API interactions.
- Routed blocking requests and detail-cover preparation through effects, retaining cached or placeholder-only redraw behavior during scroll interactions and wake/sleep handling.
- Added deployment/documentation support and expanded emulator coverage to exercise both entrypoints plus the architecture-specific event/effect behavior.
## Verification
- `python3 -c "import py_compile; py_compile.compile('main.py', cfile='/tmp/main.pyc', doraise=True); py_compile.compile('music_library.py', cfile='/tmp/music_library.pyc', doraise=True)"`
- `mise run test` (36 passed)
## Remaining Validation
- The architecture-based entrypoint has not been exercised on a physical Pimoroni Presto device.
<!-- SECTION:FINAL_SUMMARY:END -->
+25 -9
View File
@@ -56,6 +56,17 @@ That task copies `main.py` to `:main.py` and resets the Presto. It does not copy
The Presto runs `main.py` on boot, connects to WiFi, syncs time, then opens the home screen. The Presto runs `main.py` on boot, connects to WiFi, syncs time, then opens the home screen.
An equivalent Elm-style implementation is available in `music_library.py`. It
uses messages, model updates, and effects for the application runtime while
providing the same Music Library screens and API interactions. Deploy it as
the device entrypoint with:
```bash
mise run music_library
```
This task copies `music_library.py` to `:main.py` on the device and resets it.
## Usage ## Usage
**Home:** tap **Search Collection** to search by text, or **Today's Records** to open the current month with today highlighted. **Home:** tap **Search Collection** to search by text, or **Today's Records** to open the current month with today highlighted.
@@ -78,24 +89,29 @@ Run the headless smoke tests from this directory:
mise run test mise run test
``` ```
The tests use `pimoroni-emulator` mock MicroPython modules, import `main.py` without starting the event loop, and block accidental network calls. They render all screen states and save screenshots to `tests/fixtures/` for manual inspection. The tests use `pimoroni-emulator` mock MicroPython modules, import both
`main.py` and `music_library.py` without starting their event loops, and block
accidental network calls. They render all screen states and save screenshots
to `tests/fixtures/` for manual inspection. Additional runtime tests cover the
message/effect boundary in `music_library.py`.
For a syntax-only check without creating `__pycache__`: For a syntax-only check without creating `__pycache__`:
```bash ```bash
python3 -c "import py_compile; py_compile.compile('main.py', cfile='/tmp/main.pyc', doraise=True)" python3 -c "import py_compile; py_compile.compile('main.py', cfile='/tmp/main.pyc', doraise=True)"
python3 -c "import py_compile; py_compile.compile('music_library.py', cfile='/tmp/music_library.pyc', doraise=True)"
``` ```
## Troubleshooting ## Troubleshooting
| Symptom | Likely cause | Fix | | Symptom | Likely cause | Fix |
| --- | --- | --- | | ------------------------------------------ | ----------------------------------------------- | ------------------------------------ |
| `secrets.py not found` | Credentials are missing on the device | Copy `secrets.py` to the Presto root | | `secrets.py not found` | Credentials are missing on the device | Copy `secrets.py` to the Presto root |
| `WIFI_SSID not set` or `API_TOKEN not set` | Required value is blank | Check `secrets.py` | | `WIFI_SSID not set` or `API_TOKEN not set` | Required value is blank | Check `secrets.py` |
| `WiFi connection failed` | Wrong credentials or network unavailable | Verify SSID, password, and network | | `WiFi connection failed` | Wrong credentials or network unavailable | Verify SSID, password, and network |
| `Could not reach server` | API token invalid, server down, or network lost | Check `API_TOKEN` and server status | | `Could not reach server` | API token invalid, server down, or network lost | Check `API_TOKEN` and server status |
| Grey cover placeholders | JPEG decoder unavailable or image URL failed | Check firmware and internet access | | Grey cover placeholders | JPEG decoder unavailable or image URL failed | Check firmware and internet access |
| Black display after idle | Display sleep is active | Touch once to wake | | Black display after idle | Display sleep is active | Touch once to wake |
## API Contract ## API Contract
+6
View File
@@ -10,6 +10,12 @@ mpremote fs cp main.py :main.py
mpremote reset mpremote reset
''' '''
[tasks.music_library]
run = '''
mpremote fs cp music_library.py :main.py
mpremote reset
'''
[tasks.poc] [tasks.poc]
run = ''' run = '''
mpremote fs cp poc.py :main.py mpremote fs cp poc.py :main.py
File diff suppressed because it is too large Load Diff
+19 -13
View File
@@ -1,11 +1,11 @@
"""Presto smoke test configuration and helpers. """Presto smoke test configuration and helpers.
Uses the pimoroni-emulator's mock system so ``main.py`` can be imported Uses the pimoroni-emulator's mock system so the Presto entrypoints can be
on CPython without real MicroPython hardware. The emulator's headless imported on CPython without real MicroPython hardware. The emulator's
display is set up as a session-scoped fixture; ``main`` is imported once headless display is set up as a session-scoped fixture.
after the mocks are in place.
""" """
import importlib
import sys import sys
from pathlib import Path from pathlib import Path
@@ -50,25 +50,31 @@ def _emulator():
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Module fixture — import main once # Module fixture - render both complete entrypoints
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@pytest.fixture(scope="session") @pytest.fixture(scope="session", params=("main", "music_library"))
def main_module(_emulator): def main_module(_emulator, request):
"""Import ``main.py`` after the emulator mocks are installed. """Import each application after emulator mocks are installed.
The ``__name__ == "__main__"`` guard ensures ``main()`` does not run. The ``__name__ == "__main__"`` guards ensure ``main()`` does not run.
``fetch_thumbnail`` is monkey-patched to raise on any network call. ``fetch_thumbnail`` is monkey-patched to raise on any network call.
""" """
import main module = importlib.import_module(request.param)
# Guard against accidental network access in smoke tests. # Guard against accidental network access in smoke tests.
def _raise_on_network(*_args, **_kwargs): def _raise_on_network(*_args, **_kwargs):
raise RuntimeError("Network call in smoke test use mock data") raise RuntimeError("Network call in smoke test - use mock data")
main.fetch_thumbnail = _raise_on_network module.fetch_thumbnail = _raise_on_network
return main return module
@pytest.fixture(scope="session")
def music_library_module(_emulator):
"""Import the architecture-based application for transition tests."""
return importlib.import_module("music_library")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -0,0 +1,143 @@
"""Behavioral checks for the Elm-style Presto application runtime."""
import pytest
from tests.conftest import make_mock_record
@pytest.fixture(autouse=True)
def _init_display(music_library_module):
music_library_module.init_display()
def test_status_screen_renders(music_library_module):
from emulator.testing import screenshot
app = music_library_module.Model()
app.screen = music_library_module.STATE_STATUS
app.status_message = "Loading records..."
music_library_module.render(app)
screenshot("tests/fixtures/status-music-library.png")
def test_detail_render_does_not_fetch_cover(music_library_module, monkeypatch):
app = music_library_module.Model()
app.screen = music_library_module.STATE_RECORD
app.detail.selected_index = 0
app.detail.source_screen = music_library_module.STATE_DAY
rec = make_mock_record()
rec["_detail_thumb_failed"] = False
app.day.records = [rec]
def _raise_on_fetch(*_args):
raise RuntimeError("render attempted network work")
monkeypatch.setattr(music_library_module, "fetch_thumbnail", _raise_on_fetch)
music_library_module.draw_record_detail(app)
def test_detail_cover_is_prepared_by_effect_message(music_library_module, monkeypatch):
app = music_library_module.Model()
rec = make_mock_record()
rec["_detail_thumb_failed"] = False
app.day.records = [rec]
monkeypatch.setattr(
music_library_module,
"fetch_thumbnail",
lambda *_args: b"prepared-jpeg",
)
commands = music_library_module.open_record_update(
app, music_library_module.STATE_DAY, 0
)
assert commands == [
(music_library_module.FX_RENDER,),
(music_library_module.FX_PREPARE_DETAIL,),
]
assert app.screen == music_library_module.STATE_STATUS
msg = music_library_module.run_effect(
app, (music_library_module.FX_PREPARE_DETAIL,)
)
assert app.day.records[0]["_detail_thumb_data"] is None
render_commands = music_library_module.update(app, msg)
assert app.screen == music_library_module.STATE_RECORD
assert app.day.records[0]["_detail_thumb_data"] == b"prepared-jpeg"
assert render_commands == [(music_library_module.FX_RENDER,)]
def test_wake_touch_is_consumed(music_library_module):
app = music_library_module.Model()
app.screen = music_library_module.STATE_HOME
app.touch.display_awake = False
commands = music_library_module.update(
app,
(
music_library_module.MSG_TOUCH_DOWN,
music_library_module.HOME_BUTTON_X + 1,
music_library_module.HOME_BUTTON2_Y + 1,
1_000,
),
)
assert commands == [(music_library_module.FX_WAKE_DISPLAY,)]
assert app.touch.consume_until_release is True
assert music_library_module.update(app, (music_library_module.MSG_TOUCH_UP,)) == []
assert app.screen == music_library_module.STATE_HOME
def test_search_effect_reconnects_before_request(music_library_module, monkeypatch):
app = music_library_module.Model()
calls = []
monkeypatch.setattr(music_library_module, "wifi_connected", lambda: False)
monkeypatch.setattr(
music_library_module,
"connect_wifi_effect",
lambda _app: (calls.append("connect") or True, "127.0.0.1", "token"),
)
monkeypatch.setattr(
music_library_module,
"fetch_search_results",
lambda _app, _query: (calls.append("fetch") or [], False),
)
msg = music_library_module.load_search_effect(app, "blue")
assert calls == ["connect", "fetch"]
assert msg == (music_library_module.MSG_SEARCH_LOADED, [], False, 0)
def test_drag_updates_are_throttled_through_messages(music_library_module):
app = music_library_module.Model()
app.screen = music_library_module.STATE_DAY
app.day.content_height = 1_000
app.touch.last_touch = 0
assert music_library_module.update(
app, (music_library_module.MSG_TOUCH_DOWN, 100, 250, 1_000)
) == []
commands = music_library_module.update(
app,
(
music_library_module.MSG_TOUCH_MOVE,
150,
1_000 + music_library_module.DRAG_REDRAW_MS,
),
)
assert commands == [(music_library_module.FX_RENDER_PARTIAL,)]
assert app.day.scroll_offset == 100
assert app.touch.dragging is True
assert music_library_module.update(
app, (music_library_module.MSG_TOUCH_UP,)
) == [(music_library_module.FX_RENDER_PARTIAL,)]
assert app.touch.dragging is False
+6 -4
View File
@@ -1,4 +1,4 @@
"""Smoke tests verify every Presto screen renders without crashing. """Smoke tests - verify every complete Presto entrypoint renders its screens.
Each test: Each test:
1. Creates an ``AppState`` (requires ``init_display`` fixture). 1. Creates an ``AppState`` (requires ``init_display`` fixture).
@@ -7,7 +7,7 @@ Each test:
4. Asserts no exception occurred. 4. Asserts no exception occurred.
5. Saves a screenshot to ``presto/tests/fixtures/`` for manual inspection. 5. Saves a screenshot to ``presto/tests/fixtures/`` for manual inspection.
No network calls are made ``fetch_thumbnail`` is monkey-patched to raise, No network calls are made - ``fetch_thumbnail`` is monkey-patched to raise,
and mock records have ``_thumb_failed=True`` / ``_thumb_data=None``. and mock records have ``_thumb_failed=True`` / ``_thumb_data=None``.
""" """
@@ -18,7 +18,7 @@ from tests.conftest import make_mock_record
@pytest.mark.usefixtures("init_display") @pytest.mark.usefixtures("init_display")
class TestSmokeScreens: class TestSmokeScreens:
"""Smoke tests for all 7 Presto screens.""" """Smoke tests for all visible screens in both complete applications."""
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Helpers # Helpers
@@ -251,7 +251,9 @@ class TestSmokeScreens:
app.day.records = [rec] app.day.records = [rec]
app.detail.selected_index = 0 app.detail.selected_index = 0
app.detail.source_screen = main_module.STATE_DAY app.detail.source_screen = main_module.STATE_DAY
main_module._measure_detail_content(app, rec) detail_height = main_module._measure_detail_content(app, rec)
if detail_height is not None:
app.detail.content_height = detail_height
app.detail.scroll_offset = main_module._max_detail_scroll_offset(app) app.detail.scroll_offset = main_module._max_detail_scroll_offset(app)
calls = [] calls = []