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.
227 lines
11 KiB
Python
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
|