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.
179 lines
7.9 KiB
Python
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
|