Files
espresso_frame/server/tests/test_button_actions.py
T
tfaour fcf3aec4c0
Build and push server image / test (push) Successful in 36s
Firmware build check / build-check (push) Successful in 2m4s
Build and push server image / build-and-push (push) Successful in 3m12s
Build and push server image / deploy (push) Successful in 58s
Move button actions to per-widget config, add hold-for-global-action
Next/back button assignment moves from a frame-level "Button
assignments" card into each widget's own gear-icon dialog, prefilled
with a sane default at creation (photos/calendar -> advance/back,
whiteboard/weather -> check_now, others -> none). At most one binding
per (widget, button) now -- cross-widget execution order never
mattered since each widget's action only touches its own state.

New firmware capability: holding NEXT or BACK past a configurable
duration (min 3s, server-side default) triggers a frame-wide action
instead of the per-widget short-press one -- cycling saved layouts,
refreshing all widgets, or freezing/unfreezing every photo widget (see
app/global_actions.py). Firmware next/back checks gain the same
hold-duration polling the combo button already had; the threshold
comes from the previous wake's /frame/config fetch (persisted in NVS),
since this wake's button decision happens before that request.

Not done here: firmware/version.txt is intentionally left unbumped --
this hasn't been built or hardware-tested (no ESP-IDF toolchain in this
environment), so no firmware release build should be triggered yet.
2026-07-27 22:09:33 +00:00

191 lines
8.8 KiB
Python

"""POST /api/frames/{id}/widgets/{widget_id}/button-actions -- each
widget's own NEXT/BACK binding editor (see routers/api_widgets.py's
api_widget_button_actions and models.FrameButtonAction), replacing the
old frame-level "Button assignments" card. Covers the CRUD/validation
layer; multi-widget dispatch and partial-failure-continues on an actual
button press are already exercised end-to-end in
test_device_widget_dispatch.py and routers/device.py's
_run_button_actions -- this file doesn't re-test device.py's dispatch,
just that each widget's own binding endpoint stores/validates what its
dialog edits."""
from __future__ import annotations
import time
from app.models import Frame, FrameButtonAction, Widget, WhiteboardWidgetConfig
from .conftest import csrf_headers, link_user, login, make_user
def _photo_widget_id(db_session) -> int:
return db_session.query(Widget).filter_by(frame_id=1, widget_type="photos").one().id
def _shrink_default_widget(client, db_session, w=4, h=5) -> int:
"""Frees up the right-hand side of the grid for a second widget."""
widget_id = _photo_widget_id(db_session)
resp = client.patch(f"/api/frames/1/widgets/{widget_id}",
json={"x": 0, "y": 0, "w": w, "h": h}, headers=csrf_headers(client))
assert resp.status_code == 200, resp.text
return widget_id
def _add_whiteboard_widget(db_session, frame: Frame) -> Widget:
widget = Widget(frame_id=frame.id, widget_type="whiteboard", x=0, y=0, w=8, h=5,
sort_order=1, created_at=time.time())
db_session.add(widget)
db_session.flush()
db_session.add(WhiteboardWidgetConfig(widget_id=widget.id))
db_session.commit()
return widget
def test_new_widget_gets_the_sane_default_binding(client, db_session):
"""A brand new photos widget (via the API, not the migration
backfill) should already have NEXT -> advance, BACK -> back from
widgets.default_button_actions -- no separate save required."""
client.post("/setup", data={"username": "alice", "password": "hunter22"})
_shrink_default_widget(client, db_session)
resp = client.post("/api/frames/1/widgets", json={"widget_type": "calendar"},
headers=csrf_headers(client))
assert resp.status_code == 200, resp.text
widget_id = resp.json()["id"]
rows = db_session.query(FrameButtonAction).filter_by(widget_id=widget_id).all()
assert {r.button: r.action for r in rows} == {"next": "advance", "back": "back"}
def test_new_whiteboard_widget_gets_check_now_on_both_buttons(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
_shrink_default_widget(client, db_session)
resp = client.post("/api/frames/1/widgets", json={"widget_type": "whiteboard"},
headers=csrf_headers(client))
assert resp.status_code == 200, resp.text
widget_id = resp.json()["id"]
rows = db_session.query(FrameButtonAction).filter_by(widget_id=widget_id).all()
assert {r.button: r.action for r in rows} == {"next": "check_now", "back": "check_now"}
def test_new_tasks_widget_gets_no_default_bindings(client, db_session):
"""tasks has an empty ACTIONS -- nothing sane to default to."""
client.post("/setup", data={"username": "alice", "password": "hunter22"})
_shrink_default_widget(client, db_session)
resp = client.post("/api/frames/1/widgets", json={"widget_type": "tasks"},
headers=csrf_headers(client))
assert resp.status_code == 200, resp.text
widget_id = resp.json()["id"]
assert db_session.query(FrameButtonAction).filter_by(widget_id=widget_id).count() == 0
def test_save_updates_an_existing_binding(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget_id = _photo_widget_id(db_session)
resp = client.post(f"/api/frames/1/widgets/{widget_id}/button-actions",
json={"next_button_action": "back", "back_button_action": "advance"},
headers=csrf_headers(client))
assert resp.status_code == 200, resp.text
rows = {r.button: r.action for r in db_session.query(FrameButtonAction).filter_by(widget_id=widget_id)}
assert rows == {"next": "back", "back": "advance"}
def test_save_empty_string_clears_the_binding(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget_id = _photo_widget_id(db_session)
resp = client.post(f"/api/frames/1/widgets/{widget_id}/button-actions",
json={"next_button_action": "", "back_button_action": "back"},
headers=csrf_headers(client))
assert resp.status_code == 200, resp.text
rows = {r.button: r.action for r in db_session.query(FrameButtonAction).filter_by(widget_id=widget_id)}
assert rows == {"back": "back"}
def test_save_rejects_action_the_widget_type_does_not_support(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget_id = _photo_widget_id(db_session)
resp = client.post(f"/api/frames/1/widgets/{widget_id}/button-actions",
json={"next_button_action": "check_now", "back_button_action": ""},
headers=csrf_headers(client))
assert resp.status_code == 400
# Nothing partially applied -- the old default binding is untouched.
assert db_session.query(FrameButtonAction).filter_by(widget_id=widget_id, button="next").one().action == "advance"
def test_save_is_one_binding_per_widget_per_button(client, db_session):
"""Saving twice for the same button updates the one row in place,
it never accumulates a second row (matches the new unique index on
(widget_id, button))."""
client.post("/setup", data={"username": "alice", "password": "hunter22"})
frame = db_session.get(Frame, 1)
widget_id = _photo_widget_id(db_session)
for action in ("advance", "back", "advance"):
resp = client.post(f"/api/frames/1/widgets/{widget_id}/button-actions",
json={"next_button_action": action, "back_button_action": "back"},
headers=csrf_headers(client))
assert resp.status_code == 200, resp.text
assert db_session.query(FrameButtonAction).filter_by(widget_id=widget_id, button="next").count() == 1
def test_save_404s_for_unknown_widget(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
resp = client.post("/api/frames/1/widgets/999999/button-actions",
json={"next_button_action": "advance", "back_button_action": "back"},
headers=csrf_headers(client))
assert resp.status_code == 404
def test_save_unrelated_user_404s(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
make_user(db_session, "mallory")
widget_id = _photo_widget_id(db_session)
client.cookies.clear()
login(client, "mallory")
resp = client.post(f"/api/frames/1/widgets/{widget_id}/button-actions",
json={"next_button_action": "back", "back_button_action": "advance"},
headers=csrf_headers(client))
assert resp.status_code == 404
assert db_session.query(FrameButtonAction).filter_by(widget_id=widget_id, button="next").one().action == "advance"
def test_save_linked_but_not_controlling_user_409s(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
bob = make_user(db_session, "bob")
frame = db_session.get(Frame, 1)
link_user(db_session, bob, frame)
widget_id = _photo_widget_id(db_session)
client.cookies.clear()
login(client, "bob")
resp = client.post(f"/api/frames/1/widgets/{widget_id}/button-actions",
json={"next_button_action": "back", "back_button_action": "advance"},
headers=csrf_headers(client))
assert resp.status_code == 409
assert resp.json()["detail"]["error"] == "not_controller"
def test_two_widgets_of_the_same_type_have_independent_bindings(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
photo_widget_id = _photo_widget_id(db_session)
frame = db_session.get(Frame, 1)
board = _add_whiteboard_widget(db_session, frame)
resp = client.post(f"/api/frames/1/widgets/{board.id}/button-actions",
json={"next_button_action": "check_now", "back_button_action": ""},
headers=csrf_headers(client))
assert resp.status_code == 200, resp.text
photo_rows = {r.button: r.action for r in db_session.query(FrameButtonAction).filter_by(widget_id=photo_widget_id)}
board_rows = {r.button: r.action for r in db_session.query(FrameButtonAction).filter_by(widget_id=board.id)}
assert photo_rows == {"next": "advance", "back": "back"}
assert board_rows == {"next": "check_now"}