Files
espresso_frame/server/tests/test_global_actions.py
T
tfaour fcf3aec4c0
Build and push server image / test (push) Successful in 36s
Firmware build check / build-check (push) Successful in 2m4s
Build and push server image / build-and-push (push) Successful in 3m12s
Build and push server image / deploy (push) Successful in 58s
Move button actions to per-widget config, add hold-for-global-action
Next/back button assignment moves from a frame-level "Button
assignments" card into each widget's own gear-icon dialog, prefilled
with a sane default at creation (photos/calendar -> advance/back,
whiteboard/weather -> check_now, others -> none). At most one binding
per (widget, button) now -- cross-widget execution order never
mattered since each widget's action only touches its own state.

New firmware capability: holding NEXT or BACK past a configurable
duration (min 3s, server-side default) triggers a frame-wide action
instead of the per-widget short-press one -- cycling saved layouts,
refreshing all widgets, or freezing/unfreezing every photo widget (see
app/global_actions.py). Firmware next/back checks gain the same
hold-duration polling the combo button already had; the threshold
comes from the previous wake's /frame/config fetch (persisted in NVS),
since this wake's button decision happens before that request.

Not done here: firmware/version.txt is intentionally left unbumped --
this hasn't been built or hardware-tested (no ESP-IDF toolchain in this
environment), so no firmware release build should be triggered yet.
2026-07-27 22:09:33 +00:00

295 lines
12 KiB
Python

"""Hold-for-global-action (see app/global_actions.py): the
Configuration-tab save path (hold_duration_ms/next_hold_action/
back_hold_action, folded into api_frames.py's api_config_save), the
device-facing dispatch (/frame/global-next, /frame/global-back in
routers/device.py), and the three registry actions themselves
(cycle_layout, refresh_all_widgets, toggle_all_photo_locks)."""
from __future__ import annotations
import time
from app import global_actions, widgets
from app.models import (
CalendarWidgetConfig,
Frame,
PhotoWidgetConfig,
SavedLayout,
Widget,
WhiteboardWidgetConfig,
)
from .conftest import csrf_headers, link_user, login, make_user
EXPECTED_BYTES = 800 * 480 // 2
# --- Configuration-tab save (api_frames.py's api_config_save) --------------
def test_save_hold_duration_clamps_to_range(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
resp = client.post("/api/frames/1/config", data={"hold_duration_ms": "999"}, headers=csrf_headers(client))
assert resp.status_code == 200, resp.text
assert db_session.get(Frame, 1).hold_duration_ms == 3000 # MIN_HOLD_DURATION_MS
resp = client.post("/api/frames/1/config", data={"hold_duration_ms": "999999"}, headers=csrf_headers(client))
assert resp.status_code == 200, resp.text
assert db_session.get(Frame, 1).hold_duration_ms == 10000 # MAX_HOLD_DURATION_MS
def test_save_sets_and_clears_hold_actions(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
resp = client.post(
"/api/frames/1/config",
data={"next_hold_action": "cycle_layout", "back_hold_action": "refresh_all_widgets"},
headers=csrf_headers(client),
)
assert resp.status_code == 200, resp.text
frame = db_session.get(Frame, 1)
assert frame.next_hold_action == "cycle_layout"
assert frame.back_hold_action == "refresh_all_widgets"
resp = client.post(
"/api/frames/1/config", data={"next_hold_action": "", "back_hold_action": ""},
headers=csrf_headers(client),
)
assert resp.status_code == 200, resp.text
db_session.refresh(frame)
assert frame.next_hold_action is None
assert frame.back_hold_action is None
def test_save_unknown_action_clears_to_none(client, db_session):
"""Not a 400 -- same silent-normalize posture as this endpoint's
other enum-ish fields (orientation, timezone)."""
client.post("/setup", data={"username": "alice", "password": "hunter22"})
resp = client.post(
"/api/frames/1/config", data={"next_hold_action": "not_a_real_action"},
headers=csrf_headers(client),
)
assert resp.status_code == 200, resp.text
assert db_session.get(Frame, 1).next_hold_action is None
def test_save_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)
client.cookies.clear()
login(client, "bob")
resp = client.post(
"/api/frames/1/config", data={"next_hold_action": "cycle_layout"}, headers=csrf_headers(client)
)
assert resp.status_code == 409
assert resp.json()["detail"]["error"] == "not_controller"
def test_save_unrelated_user_404s(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
make_user(db_session, "mallory")
client.cookies.clear()
login(client, "mallory")
resp = client.post(
"/api/frames/1/config", data={"next_hold_action": "cycle_layout"}, headers=csrf_headers(client)
)
assert resp.status_code == 404
assert db_session.get(Frame, 1).next_hold_action is None
def test_save_logged_out_401s(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
client.cookies.clear()
resp = client.post("/api/frames/1/config", data={"next_hold_action": "cycle_layout"})
assert resp.status_code == 401
# --- Device dispatch (/frame/global-next, /frame/global-back) --------------
def test_global_next_is_a_noop_when_unset(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
resp = client.post("/frame/global-next")
assert resp.status_code == 200
assert len(resp.content) == EXPECTED_BYTES
def test_global_next_runs_the_configured_action(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
frame = db_session.get(Frame, 1)
photo_widget_id = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one().id
frame.next_hold_action = "toggle_all_photo_locks"
db_session.commit()
resp = client.post("/frame/global-next")
assert resp.status_code == 200
assert len(resp.content) == EXPECTED_BYTES
assert db_session.get(PhotoWidgetConfig, photo_widget_id).locked is True
def test_global_back_runs_the_configured_action(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
frame = db_session.get(Frame, 1)
photo_widget_id = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one().id
frame.back_hold_action = "toggle_all_photo_locks"
db_session.commit()
resp = client.post("/frame/global-back")
assert resp.status_code == 200
assert db_session.get(PhotoWidgetConfig, photo_widget_id).locked is True
def test_global_next_with_an_unrecognized_stored_action_is_a_noop(client, db_session):
"""Defensive -- a value that stopped being a valid registry key
(e.g. after a downgrade) shouldn't 500 the whole request."""
client.post("/setup", data={"username": "alice", "password": "hunter22"})
frame = db_session.get(Frame, 1)
frame.next_hold_action = "no_longer_exists"
db_session.commit()
resp = client.post("/frame/global-next")
assert resp.status_code == 200
assert len(resp.content) == EXPECTED_BYTES
# --- Registry: toggle_all_photo_locks ---------------------------------------
def test_toggle_all_photo_locks_locks_then_unlocks(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
frame = db_session.get(Frame, 1)
photo_widget_id = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one().id
global_actions.toggle_all_photo_locks(db_session, frame)
assert db_session.get(PhotoWidgetConfig, photo_widget_id).locked is True
global_actions.toggle_all_photo_locks(db_session, frame)
assert db_session.get(PhotoWidgetConfig, photo_widget_id).locked is False
def test_toggle_all_photo_locks_noop_with_no_photo_widgets(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
frame = db_session.get(Frame, 1)
widget = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one()
db_session.delete(widget)
db_session.commit()
global_actions.toggle_all_photo_locks(db_session, frame) # should not raise
# --- Registry: refresh_all_widgets ------------------------------------------
def test_refresh_all_widgets_only_calls_check_now_capable_types(client, db_session, monkeypatch):
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 = Widget(frame_id=frame.id, widget_type="whiteboard", x=0, y=0, w=1, h=1,
sort_order=1, created_at=time.time())
db_session.add(board_widget)
db_session.flush()
db_session.add(WhiteboardWidgetConfig(widget_id=board_widget.id))
db_session.commit()
calls = []
monkeypatch.setattr(
widgets.whiteboard, "get_or_refresh_whiteboard_for_widget",
lambda db, frame, widget, force=False: calls.append((widget.id, force)),
)
global_actions.refresh_all_widgets(db_session, frame)
# Only the whiteboard widget has a check_now action -- the photos
# widget (no check_now in its ACTIONS) is left untouched.
assert calls == [(board_widget.id, True)]
assert photo_widget.id not in [c[0] for c in calls]
def test_refresh_all_widgets_one_failure_does_not_block_others(client, db_session, monkeypatch):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
frame = db_session.get(Frame, 1)
board_a = Widget(frame_id=frame.id, widget_type="whiteboard", x=0, y=0, w=1, h=1,
sort_order=1, created_at=time.time())
board_b = Widget(frame_id=frame.id, widget_type="whiteboard", x=1, y=0, w=1, h=1,
sort_order=2, created_at=time.time())
db_session.add_all([board_a, board_b])
db_session.flush()
db_session.add_all([WhiteboardWidgetConfig(widget_id=board_a.id), WhiteboardWidgetConfig(widget_id=board_b.id)])
db_session.commit()
calls = []
def _flaky(db, frame, widget, force=False):
if widget.id == board_a.id:
raise RuntimeError("simulated fetch failure")
calls.append(widget.id)
monkeypatch.setattr(widgets.whiteboard, "get_or_refresh_whiteboard_for_widget", _flaky)
global_actions.refresh_all_widgets(db_session, frame) # should not raise
assert calls == [board_b.id]
# --- Registry: cycle_layout --------------------------------------------------
def test_cycle_layout_noop_when_frame_unclaimed(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
frame = db_session.get(Frame, 1)
frame.owner_user_id = None
db_session.commit()
global_actions.cycle_layout(db_session, frame) # should not raise
assert db_session.query(Widget).filter_by(frame_id=frame.id).count() == 1 # untouched
def test_cycle_layout_noop_when_no_compatible_layouts(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
frame = db_session.get(Frame, 1)
global_actions.cycle_layout(db_session, frame) # no saved layouts at all yet
assert db_session.query(Widget).filter_by(frame_id=frame.id).count() == 1
def test_cycle_layout_applies_and_wraps_around(client, db_session):
client.post("/setup", data={"username": "alice", "password": "hunter22"})
frame = db_session.get(Frame, 1)
resp_a = client.post("/api/frames/1/layouts", json={"name": "Layout A"}, headers=csrf_headers(client))
assert resp_a.status_code == 200, resp_a.text
layout_a_id = resp_a.json()["id"]
# A second, differently-shaped widget arrangement to save as "Layout B".
photo_widget = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one()
photo_widget.w = 4
cal_widget = Widget(frame_id=frame.id, widget_type="calendar", x=4, y=0, w=4, h=5,
sort_order=1, created_at=time.time())
db_session.add(cal_widget)
db_session.flush()
db_session.add(CalendarWidgetConfig(widget_id=cal_widget.id))
db_session.commit()
resp_b = client.post("/api/frames/1/layouts", json={"name": "Layout B"}, headers=csrf_headers(client))
assert resp_b.status_code == 200, resp_b.text
layout_b_id = resp_b.json()["id"]
# A layout with the wrong grid dims must never be selected.
incompatible = SavedLayout(user_id=frame.owner_user_id, name="Wrong size", cols=1, rows=1,
created_at=time.time(), updated_at=time.time())
db_session.add(incompatible)
db_session.commit()
global_actions.cycle_layout(db_session, frame)
db_session.refresh(frame)
assert frame.last_cycled_layout_id == layout_a_id
global_actions.cycle_layout(db_session, frame)
db_session.refresh(frame)
assert frame.last_cycled_layout_id == layout_b_id
global_actions.cycle_layout(db_session, frame) # wraps back to the first
db_session.refresh(frame)
assert frame.last_cycled_layout_id == layout_a_id