Files
espresso_frame/server/tests/test_widget_placement.py
T
tfaour 5d4bb53b8a
Build and push server image / test (push) Successful in 19s
Build and push server image / build-and-push (push) Successful in 2m32s
Widget system Phase 4a: widget CRUD + grid placement UI
Adds the actual "Android home screen" placement experience: a new
Layout tab with a pointer-driven canvas for dragging/resizing widgets
and adding new ones from a type picker. Backed by a new
routers/api_widgets.py (create/move/delete), which re-validates
bounds, minimum footprint, and no-overlap server-side regardless of
what the client already checked. A widget added without an explicit
position lands in the first open space that fits it
(grid.find_open_rect), so users don't have to hunt for empty space
themselves.

Also fixes a real latent bug this surfaced: changing a frame's
orientation swaps the widget grid's long/short axis, which left
existing widget placements out of bounds on the new grid with no
render-time safeguard. Orientation changes now reset the layout to a
single full-panel widget (keeping the first widget's type, dropping
the rest), with a client-side confirm before it happens.
2026-07-24 12:48:56 -04:00

227 lines
11 KiB
Python

"""routers/api_widgets.py -- widget CRUD + grid placement. Bounds/
minimum-footprint/no-overlap validation is server-side and re-checked
regardless of what a client already believes is a valid placement (see
the module's own docstring), so this is exercised at the HTTP layer, not
just against grid.py's pure functions directly (those have no dedicated
coverage of their own -- this file and test_migrations.py's backfill
tests are what actually exercise them end to end).
Frame #1's auto-migrated widget (see migration.py's backfill) is a
single full-panel (0, 0, 8, 5) photos widget -- every test here starts
by shrinking or removing it to free up room, mirroring what a real user
would do on the placement canvas before adding a second widget."""
from __future__ import annotations
from app.models import Frame, FrameButtonAction, Widget
from .conftest import csrf_headers
def _widget_id(db_session, widget_type="photos") -> int:
return db_session.query(Widget).filter_by(frame_id=1, widget_type=widget_type).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 = _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 test_list_widgets_returns_grid_and_the_default_widget(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
resp = client.get("/api/frames/1/widgets")
assert resp.status_code == 200
data = resp.json()
assert data["orientation"] == "landscape"
assert data["grid"] == {"cols": 8, "rows": 5}
assert len(data["widgets"]) == 1
assert data["widgets"][0]["widget_type"] == "photos"
assert data["widgets"][0]["w"] == 8 and data["widgets"][0]["h"] == 5
def test_create_with_no_room_left_is_rejected(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
resp = client.post("/api/frames/1/widgets", json={"widget_type": "photos"}, headers=csrf_headers(client))
assert resp.status_code == 400
assert "no open space" in resp.json()["detail"].lower()
def test_create_auto_places_in_freed_up_space(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
_shrink_default_widget(client, db_session, w=4, h=5)
resp = client.post("/api/frames/1/widgets", json={"widget_type": "whiteboard"}, headers=csrf_headers(client))
assert resp.status_code == 200, resp.text
created = resp.json()
assert created["widget_type"] == "whiteboard"
assert created["x"] >= 4 # lands in the freed right-hand region, not overlapping the shrunk photos widget
assert (created["w"], created["h"]) == (2, 2) # grid.MIN_FOOTPRINT["whiteboard"]
widget = db_session.get(Widget, created["id"])
assert widget is not None and widget.frame_id == 1
# A default config row was created alongside it (see WIDGET_CONFIG_MODELS).
from app.models import WhiteboardWidgetConfig
assert db_session.get(WhiteboardWidgetConfig, widget.id) is not None
def test_create_with_explicit_placement_validates_bounds(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
_shrink_default_widget(client, db_session, w=4, h=5)
resp = client.post("/api/frames/1/widgets",
json={"widget_type": "photos", "x": 6, "y": 0, "w": 4, "h": 2},
headers=csrf_headers(client))
assert resp.status_code == 400
assert "out of bounds" in resp.json()["detail"].lower()
def test_create_below_minimum_footprint_is_rejected(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
_shrink_default_widget(client, db_session, w=4, h=5)
resp = client.post("/api/frames/1/widgets",
json={"widget_type": "calendar", "x": 4, "y": 0, "w": 2, "h": 1},
headers=csrf_headers(client))
assert resp.status_code == 400
assert "at least" in resp.json()["detail"].lower()
def test_create_overlapping_existing_widget_is_rejected(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
# Default widget still covers the full 8x5 grid -- any explicit placement overlaps it.
resp = client.post("/api/frames/1/widgets",
json={"widget_type": "whiteboard", "x": 0, "y": 0, "w": 2, "h": 2},
headers=csrf_headers(client))
assert resp.status_code == 400
assert "overlaps" in resp.json()["detail"].lower()
def test_create_unknown_widget_type_is_rejected(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
resp = client.post("/api/frames/1/widgets", json={"widget_type": "video"}, headers=csrf_headers(client))
assert resp.status_code == 400
assert "unknown widget type" in resp.json()["detail"].lower()
def test_move_widget_to_a_valid_rect(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget_id = _widget_id(db_session)
resp = client.patch(f"/api/frames/1/widgets/{widget_id}",
json={"x": 1, "y": 1, "w": 3, "h": 2}, headers=csrf_headers(client))
assert resp.status_code == 200, resp.text
widget = db_session.get(Widget, widget_id)
assert (widget.x, widget.y, widget.w, widget.h) == (1, 1, 3, 2)
def test_move_rejects_overlap_with_another_widget(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
photos_id = _shrink_default_widget(client, db_session, w=4, h=5)
create_resp = client.post("/api/frames/1/widgets", json={"widget_type": "whiteboard"},
headers=csrf_headers(client))
whiteboard_id = create_resp.json()["id"]
# Try to move photos back over the whiteboard widget's space.
resp = client.patch(f"/api/frames/1/widgets/{photos_id}",
json={"x": 0, "y": 0, "w": 8, "h": 5}, headers=csrf_headers(client))
assert resp.status_code == 400
assert "overlaps" in resp.json()["detail"].lower()
# Original placement is untouched after the rejected move.
widget = db_session.get(Widget, photos_id)
assert (widget.x, widget.y, widget.w, widget.h) == (0, 0, 4, 5)
assert whiteboard_id # sanity: the other widget really was created
def test_move_unknown_widget_404s(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
resp = client.patch("/api/frames/1/widgets/999999",
json={"x": 0, "y": 0, "w": 1, "h": 1}, headers=csrf_headers(client))
assert resp.status_code == 404
def test_delete_widget_and_cascades_button_actions(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget_id = _widget_id(db_session)
db_session.add(FrameButtonAction(frame_id=1, button="next", widget_id=widget_id, action="advance", sort_order=0))
db_session.commit()
resp = client.delete(f"/api/frames/1/widgets/{widget_id}", headers=csrf_headers(client))
assert resp.status_code == 200
assert db_session.get(Widget, widget_id) is None
remaining = db_session.query(FrameButtonAction).filter_by(widget_id=widget_id).all()
assert remaining == []
def test_delete_unknown_widget_404s(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
resp = client.delete("/api/frames/1/widgets/999999", headers=csrf_headers(client))
assert resp.status_code == 404
def test_widgets_scoped_to_their_own_frame(client, db_session):
"""A widget id from a different frame must 404, not silently operate
cross-frame -- same posture as photo_widget_config_or_404 and every
other frame-scoped lookup in this codebase."""
client.post("/setup", data={"username": "alice", "password": "hunter22"})
other_frame = Frame(name="Second frame", manage_token="tok-2", device_id="dev-2", device_token="dtok-2")
db_session.add(other_frame)
db_session.commit()
from app.models import Widget as W
other_widget = W(frame_id=other_frame.id, widget_type="photos", x=0, y=0, w=8, h=5, sort_order=0,
created_at=0)
db_session.add(other_widget)
db_session.commit()
resp = client.patch(f"/api/frames/1/widgets/{other_widget.id}",
json={"x": 0, "y": 0, "w": 1, "h": 1}, headers=csrf_headers(client))
assert resp.status_code == 404
# --- orientation change resets the widget layout (grid.grid_dims transposes) ---
def test_orientation_change_resets_multiple_widgets_to_one_full_panel_widget(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
photos_id = _shrink_default_widget(client, db_session, w=4, h=5)
create_resp = client.post("/api/frames/1/widgets", json={"widget_type": "whiteboard"},
headers=csrf_headers(client))
whiteboard_id = create_resp.json()["id"]
assert db_session.query(Widget).filter_by(frame_id=1).count() == 2
resp = client.post("/api/frames/1/config", data={"orientation": "portrait"}, headers=csrf_headers(client))
assert resp.status_code == 200, resp.text
remaining = db_session.query(Widget).filter_by(frame_id=1).all()
assert len(remaining) == 1
assert remaining[0].id == photos_id # first by sort_order survives
assert (remaining[0].x, remaining[0].y, remaining[0].w, remaining[0].h) == (0, 0, 5, 8) # full portrait panel
assert db_session.get(Widget, whiteboard_id) is None
frame = db_session.get(Frame, 1)
assert frame.orientation == "portrait"
def test_orientation_unchanged_leaves_widget_layout_alone(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
_shrink_default_widget(client, db_session, w=4, h=5)
resp = client.post("/api/frames/1/config", data={"orientation": "landscape"}, headers=csrf_headers(client))
assert resp.status_code == 200, resp.text
widget = db_session.query(Widget).filter_by(frame_id=1).one()
assert (widget.x, widget.y, widget.w, widget.h) == (0, 0, 4, 5) # untouched -- orientation didn't actually change
def test_orientation_change_with_no_widgets_is_a_no_op(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
widget_id = _widget_id(db_session)
client.delete(f"/api/frames/1/widgets/{widget_id}", headers=csrf_headers(client))
assert db_session.query(Widget).filter_by(frame_id=1).count() == 0
resp = client.post("/api/frames/1/config", data={"orientation": "portrait"}, headers=csrf_headers(client))
assert resp.status_code == 200, resp.text
assert db_session.query(Widget).filter_by(frame_id=1).count() == 0