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.
191 lines
8.8 KiB
Python
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"}
|