"""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