ML-197: add Elm-style Presto application
This commit is contained in:
@@ -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 -->
|
||||||
|
|||||||
+18
-2
@@ -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,18 +89,23 @@ 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 |
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
@@ -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 = []
|
||||||
|
|||||||
Reference in New Issue
Block a user