Add per-widget border option (style, thickness, palette color)
A Widget-level property (border_style/border_thickness/border_color_index), not a per-type config field, since every widget type can have one -- drawn once centrally in device.py's _render_widgets before compositing, using an exact panel palette color so it never dithers. Styles: solid, dashed, dotted, and a fancy double-line picture-frame-mat look. Configurable from a shared "Border" card in every widget's gear-icon dialog.
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
"""routers/api_widgets.py's POST .../border endpoint (models.Widget.
|
||||
border_style/border_thickness/border_color_index) -- a Widget-level
|
||||
property, not a per-type config field, so this is exercised independently
|
||||
of any specific widget_type. End-to-end rendering (does a saved border
|
||||
actually show up in the composited panel) is covered separately in
|
||||
test_device_widget_dispatch.py-style tests below via the frame preview
|
||||
endpoint, the same real render_panel path /frame/image uses."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from app.models import Frame, Widget
|
||||
|
||||
from .conftest import csrf_headers, link_user, login, make_user
|
||||
|
||||
|
||||
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 test_new_widget_defaults_to_no_border(db_session):
|
||||
widget = db_session.query(Widget).filter_by(frame_id=1).one()
|
||||
assert widget.border_style == "none"
|
||||
assert widget.border_thickness == 3
|
||||
assert widget.border_color_index == 0
|
||||
|
||||
|
||||
def test_set_border_persists(client, db_session):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
widget_id = _widget_id(db_session)
|
||||
resp = client.post(f"/api/frames/1/widgets/{widget_id}/border",
|
||||
json={"border_style": "dashed", "border_thickness": 5, "border_color_index": 3},
|
||||
headers=csrf_headers(client))
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json() == {
|
||||
"id": widget_id, "widget_type": "photos", "x": 0, "y": 0, "w": 8, "h": 5, "sort_order": 0,
|
||||
"border_style": "dashed", "border_thickness": 5, "border_color_index": 3,
|
||||
}
|
||||
widget = db_session.get(Widget, widget_id)
|
||||
assert (widget.border_style, widget.border_thickness, widget.border_color_index) == ("dashed", 5, 3)
|
||||
|
||||
|
||||
def test_set_border_thickness_clamps_to_range(client, db_session):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
widget_id = _widget_id(db_session)
|
||||
resp = client.post(f"/api/frames/1/widgets/{widget_id}/border",
|
||||
json={"border_style": "solid", "border_thickness": 999, "border_color_index": 0},
|
||||
headers=csrf_headers(client))
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["border_thickness"] == 8 # image_pipeline.MAX_BORDER_THICKNESS
|
||||
|
||||
resp = client.post(f"/api/frames/1/widgets/{widget_id}/border",
|
||||
json={"border_style": "solid", "border_thickness": -5, "border_color_index": 0},
|
||||
headers=csrf_headers(client))
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["border_thickness"] == 1 # image_pipeline.MIN_BORDER_THICKNESS
|
||||
|
||||
|
||||
def test_set_border_rejects_unknown_style(client, db_session):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
widget_id = _widget_id(db_session)
|
||||
resp = client.post(f"/api/frames/1/widgets/{widget_id}/border",
|
||||
json={"border_style": "sparkly", "border_thickness": 3, "border_color_index": 0},
|
||||
headers=csrf_headers(client))
|
||||
assert resp.status_code == 400
|
||||
assert "border_style" in resp.json()["detail"]
|
||||
|
||||
|
||||
def test_set_border_rejects_out_of_range_color_index(client, db_session):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
widget_id = _widget_id(db_session)
|
||||
resp = client.post(f"/api/frames/1/widgets/{widget_id}/border",
|
||||
json={"border_style": "solid", "border_thickness": 3, "border_color_index": 6},
|
||||
headers=csrf_headers(client))
|
||||
assert resp.status_code == 400
|
||||
assert "border_color_index" in resp.json()["detail"]
|
||||
|
||||
|
||||
def test_set_border_404s_for_unknown_widget(client, db_session):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
resp = client.post("/api/frames/1/widgets/999999/border",
|
||||
json={"border_style": "solid", "border_thickness": 3, "border_color_index": 0},
|
||||
headers=csrf_headers(client))
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_set_border_unrelated_user_404s(client, db_session):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
make_user(db_session, "mallory")
|
||||
widget_id = _widget_id(db_session)
|
||||
|
||||
client.cookies.clear()
|
||||
login(client, "mallory")
|
||||
resp = client.post(f"/api/frames/1/widgets/{widget_id}/border",
|
||||
json={"border_style": "solid", "border_thickness": 3, "border_color_index": 0},
|
||||
headers=csrf_headers(client))
|
||||
assert resp.status_code == 404
|
||||
assert db_session.get(Widget, widget_id).border_style == "none"
|
||||
|
||||
|
||||
def test_set_border_linked_but_not_controlling_user_409s(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)
|
||||
widget_id = _widget_id(db_session)
|
||||
|
||||
client.cookies.clear()
|
||||
login(client, "bob")
|
||||
resp = client.post(f"/api/frames/1/widgets/{widget_id}/border",
|
||||
json={"border_style": "solid", "border_thickness": 3, "border_color_index": 0},
|
||||
headers=csrf_headers(client))
|
||||
assert resp.status_code == 409
|
||||
assert resp.json()["detail"]["error"] == "not_controller"
|
||||
|
||||
|
||||
# --- actually renders (real render_panel path, same as /frame/image) ------
|
||||
|
||||
def _preview_pixels(client) -> Image.Image:
|
||||
resp = client.get("/api/frames/1/preview")
|
||||
assert resp.status_code == 200, resp.text
|
||||
return Image.open(io.BytesIO(resp.content)).convert("RGB")
|
||||
|
||||
|
||||
def test_solid_border_draws_the_configured_palette_color(client, db_session):
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
widget_id = _widget_id(db_session)
|
||||
# color_index 3 == Red, see image_pipeline.PALETTE_LABELS/DEFAULT_PALETTE_RGB
|
||||
resp = client.post(f"/api/frames/1/widgets/{widget_id}/border",
|
||||
json={"border_style": "solid", "border_thickness": 6, "border_color_index": 3},
|
||||
headers=csrf_headers(client))
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
img = _preview_pixels(client)
|
||||
assert img.getpixel((0, 0)) == (207, 0, 15) # DEFAULT_PALETTE_RGB[3], top-left corner of the stroke
|
||||
|
||||
|
||||
def test_no_border_leaves_the_edge_unmarked(client, db_session):
|
||||
"""The default "none" style is a true no-op -- this is the negative
|
||||
case for test_solid_border_draws_the_configured_palette_color, using
|
||||
the exact same photo-widget fixture minus the border, so a red edge
|
||||
pixel there can only be the border, not something else in the
|
||||
render path already painting it that color."""
|
||||
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
||||
img = _preview_pixels(client)
|
||||
assert img.getpixel((0, 0)) != (207, 0, 15)
|
||||
Reference in New Issue
Block a user