Files
espresso_frame/server/tests/test_button_actions.py
T
tfaour 289d308b57
Build and push server image / test (push) Successful in 22s
Build and push server image / build-and-push (push) Successful in 2m0s
Build and push server image / deploy (push) Successful in 56s
Disambiguate same-type widgets in the button-assignment dropdowns
Two widgets of the same type (e.g. two Photos widgets) both showed up
as plain "Photos" with no way to tell which was which. The buttons API
now includes each widget's grid placement plus the frame's grid dims;
the UI derives a rough position ("top-left", "right", etc.) from that
and only appends a number+position suffix when a type actually
repeats on the frame, leaving the common single-widget-per-type case
unchanged.
2026-07-24 18:00:01 -04:00

179 lines
7.9 KiB
Python

"""GET/PUT /api/frames/{id}/buttons -- the button-assignment UI's API
(see routers/api_frames.py's api_buttons_get/api_buttons_save and
static/frame_config.js). Covers the CRUD/validation layer; multi-action
execution order 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 the assignment API stores/serves/
validates what the UI 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 _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_get_buttons_reflects_the_default_migration_mapping(client, db_session):
"""Frame #1's auto-migrated photos widget should already have NEXT ->
advance, BACK -> back from _default_button_actions (see
migration.py) -- the UI just needs to be able to see that default."""
client.post("/setup", data={"username": "alice", "password": "hunter22"})
frame = db_session.get(Frame, 1)
photo_widget = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one()
resp = client.get("/api/frames/1/buttons")
assert resp.status_code == 200
data = resp.json()
assert {w["id"]: w["widget_type"] for w in data["widgets"]} == {photo_widget.id: "photos"}
photo_actions = {a["action"] for w in data["widgets"] for a in w["actions"]}
assert photo_actions == {"advance", "back"}
assert data["next"] == [{"id": data["next"][0]["id"], "widget_id": photo_widget.id, "action": "advance"}]
assert data["back"] == [{"id": data["back"][0]["id"], "widget_id": photo_widget.id, "action": "back"}]
def test_get_buttons_includes_placement_and_grid_dims(client, db_session):
"""Two widgets of the same type otherwise look identical in the
assignment UI ("Photos" / "Photos") -- the client tells them apart
using x/y/w/h against the frame's grid dims (see
static/frame_config.js's buildWidgetNames), so the API needs to
actually hand those over."""
client.post("/setup", data={"username": "alice", "password": "hunter22"})
frame = db_session.get(Frame, 1)
photo_widget = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one()
photo_widget.x, photo_widget.y, photo_widget.w, photo_widget.h = 0, 0, 4, 5
db_session.commit()
second = Widget(frame_id=frame.id, widget_type="photos", x=4, y=0, w=4, h=5,
sort_order=1, created_at=time.time())
db_session.add(second)
db_session.flush()
from app.models import PhotoWidgetConfig
db_session.add(PhotoWidgetConfig(widget_id=second.id))
db_session.commit()
resp = client.get("/api/frames/1/buttons")
assert resp.status_code == 200
data = resp.json()
assert data["grid"] == {"cols": 8, "rows": 5}
by_id = {w["id"]: w for w in data["widgets"]}
assert by_id[photo_widget.id]["x"] == 0 and by_id[photo_widget.id]["w"] == 4
assert by_id[second.id]["x"] == 4 and by_id[second.id]["w"] == 4
def test_put_replaces_the_whole_list_in_order(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
frame = db_session.get(Frame, 1)
photo_widget = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one()
board_widget = _add_whiteboard_widget(db_session, frame)
resp = client.put("/api/frames/1/buttons/next", json={
"actions": [
{"widget_id": board_widget.id, "action": "check_now"},
{"widget_id": photo_widget.id, "action": "advance"},
],
}, headers=csrf_headers(client))
assert resp.status_code == 200, resp.text
rows = db_session.query(FrameButtonAction).filter_by(
frame_id=frame.id, button="next"
).order_by(FrameButtonAction.sort_order).all()
assert [(r.widget_id, r.action) for r in rows] == [
(board_widget.id, "check_now"), (photo_widget.id, "advance"),
]
# BACK's own default mapping (photos -> back) is untouched by a PUT to next.
back_rows = db_session.query(FrameButtonAction).filter_by(frame_id=frame.id, button="back").all()
assert len(back_rows) == 1
assert back_rows[0].action == "back"
def test_put_empty_list_clears_the_button(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
frame = db_session.get(Frame, 1)
resp = client.put("/api/frames/1/buttons/next", json={"actions": []}, headers=csrf_headers(client))
assert resp.status_code == 200, resp.text
assert db_session.query(FrameButtonAction).filter_by(frame_id=frame.id, button="next").count() == 0
def test_put_rejects_unknown_button_name(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
resp = client.put("/api/frames/1/buttons/sideways", json={"actions": []}, headers=csrf_headers(client))
assert resp.status_code == 404
def test_put_rejects_widget_from_another_frame(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
other = Frame(name="Other", device_token="tok-other", manage_token="mtok-other", created_at=time.time())
db_session.add(other)
db_session.flush()
other_widget = Widget(frame_id=other.id, widget_type="photos", x=0, y=0, w=8, h=5,
sort_order=0, created_at=time.time())
db_session.add(other_widget)
db_session.commit()
resp = client.put("/api/frames/1/buttons/next", json={
"actions": [{"widget_id": other_widget.id, "action": "advance"}],
}, headers=csrf_headers(client))
assert resp.status_code == 400
# Nothing partially applied -- the whole request is validated before any write.
assert db_session.query(FrameButtonAction).filter_by(frame_id=1, button="next").count() == 1
def test_put_rejects_action_the_widget_type_does_not_support(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
frame = db_session.get(Frame, 1)
photo_widget = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one()
resp = client.put("/api/frames/1/buttons/next", json={
"actions": [{"widget_id": photo_widget.id, "action": "check_now"}],
}, headers=csrf_headers(client))
assert resp.status_code == 400
def test_deleting_a_widget_cascades_its_button_bindings(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
frame = db_session.get(Frame, 1)
photo_widget = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one()
resp = client.delete(f"/api/frames/1/widgets/{photo_widget.id}", headers=csrf_headers(client))
assert resp.status_code == 200, resp.text
assert db_session.query(FrameButtonAction).filter_by(frame_id=frame.id).count() == 0
def test_linked_user_can_view_and_control_can_save(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)
client.cookies.clear()
login(client, "bob")
resp = client.get("/api/frames/1/buttons")
assert resp.status_code == 200
def test_unrelated_user_cannot_view(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
make_user(db_session, "mallory")
client.cookies.clear()
login(client, "mallory")
resp = client.get("/api/frames/1/buttons")
assert resp.status_code == 404