Firmware build check / build-check (push) Successful in 5m37s
Build and release firmware / build-and-release (push) Successful in 5m36s
Build and push server image / test (push) Successful in 1m37s
Build and push server image / build-and-push (push) Successful in 4m18s
Build and push server image / deploy (push) Failing after 1m20s
Server: migration 41 drops the pre-widget-system Frame columns (mode/album_id/current_asset_id/queue/calendar_*/whiteboard_*, etc) docs/widgets.md flagged as the deliberately-deferred Phase 6 cleanup, with a raw-SQL backfill safety net for any frame that still somehow lacks a Widget. Also drops legacy_token_enabled and the shared MANAGEMENT_TOKEN fallback it gated in require_device/require_browser -- the per-frame manage_token/device_token flow (and the /m/ page) fully supersede it now; MANAGEMENT_TOKEN's only remaining role is the optional pre-setup claim gate. Confirmed with the maintainer that the deployed frame is already off the shared token before removing the server-side fallback. Firmware: the captive portal's "Access Token" field and its NVS/ build_url plumbing only ever mattered for pointing new firmware at an old pre-multi-frame server -- gone along with the server-side fallback it fed. Version bump to publish the change.
197 lines
8.5 KiB
Python
197 lines
8.5 KiB
Python
"""End-to-end HTTP tests for the widget-system cutover in
|
|
routers/device.py -- /frame/image, /frame/advance, and /frame/back
|
|
against real widget rows (via the migration-backfilled frame #1, or a
|
|
purpose-built second frame), a real TestClient, real
|
|
render_panel/compose_into. Only Immich itself is mocked (monkeypatched
|
|
at the app.widgets.photos module boundary, same pattern as
|
|
test_widgets_photos.py) -- everything else in the pipeline is real.
|
|
|
|
This is the one place in the suite that actually exercises
|
|
routers/device.py's dispatch over HTTP; the render-size-invariant tests
|
|
exercise the renderers directly, and the widget unit tests exercise
|
|
app/widgets/*.py directly, but neither proves device.py's own wiring
|
|
(the compositor, the button-action runner, the manage-overlay hookup)
|
|
is correct -- that's what this file is for."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import time
|
|
|
|
from app import widgets
|
|
from app.models import (
|
|
CalendarWidgetConfig,
|
|
Frame,
|
|
FrameButtonAction,
|
|
PhotoWidgetConfig,
|
|
Widget,
|
|
)
|
|
|
|
from .conftest import claim_device
|
|
|
|
EXPECTED_BYTES = 800 * 480 // 2
|
|
_ASSETS = [{"id": "asset-1"}, {"id": "asset-2"}, {"id": "asset-3"}]
|
|
|
|
|
|
def _mock_immich(monkeypatch):
|
|
monkeypatch.setattr(widgets.photos, "immich_client_for", lambda frame: object())
|
|
monkeypatch.setattr(widgets.photos, "list_assets", lambda client, album_id: _ASSETS)
|
|
from PIL import Image
|
|
source = Image.new("RGB", (100, 80), (10, 20, 30))
|
|
monkeypatch.setattr(widgets.photos, "fetch_source_and_faces", lambda client, mode, asset_id: (source, None))
|
|
|
|
|
|
def test_unclaimed_frame_shows_placeholder(client, db_session):
|
|
frame = db_session.get(Frame, 1)
|
|
frame.owner_user_id = None
|
|
creds = claim_device(db_session, frame)
|
|
|
|
resp = client.get(f"/frame/image?{creds}")
|
|
assert resp.status_code == 200
|
|
assert len(resp.content) == EXPECTED_BYTES
|
|
|
|
|
|
def test_claimed_frame_with_unconfigured_photo_widget_still_renders(client, db_session):
|
|
client.post("/setup", data={"username": "alice", "password": "hunter22"})
|
|
creds = claim_device(db_session, db_session.get(Frame, 1))
|
|
resp = client.get(f"/frame/image?{creds}")
|
|
assert resp.status_code == 200
|
|
assert len(resp.content) == EXPECTED_BYTES
|
|
|
|
frame = db_session.get(Frame, 1)
|
|
widget = db_session.query(Widget).filter_by(frame_id=frame.id, widget_type="photos").one()
|
|
cfg = db_session.get(PhotoWidgetConfig, widget.id)
|
|
assert cfg.album_id == "" # never configured -- still rendered fine, as a placeholder region
|
|
|
|
|
|
def test_configured_photo_widget_renders_and_advances_via_button(client, db_session, monkeypatch):
|
|
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()
|
|
cfg = db_session.get(PhotoWidgetConfig, widget.id)
|
|
cfg.album_id = "album-1"
|
|
creds = claim_device(db_session, frame)
|
|
|
|
_mock_immich(monkeypatch)
|
|
|
|
resp = client.get(f"/frame/image?{creds}")
|
|
assert resp.status_code == 200
|
|
assert len(resp.content) == EXPECTED_BYTES
|
|
|
|
db_session.refresh(cfg)
|
|
assert cfg.current_asset_id == "asset-1"
|
|
|
|
resp = client.post(f"/frame/advance?{creds}")
|
|
assert resp.status_code == 200
|
|
assert len(resp.content) == EXPECTED_BYTES
|
|
db_session.refresh(cfg)
|
|
assert cfg.current_asset_id != "asset-1" # the default next->advance binding fired
|
|
|
|
resp = client.post(f"/frame/back?{creds}")
|
|
assert resp.status_code == 200
|
|
db_session.refresh(cfg)
|
|
assert cfg.current_asset_id == "asset-1" # back undid it
|
|
|
|
|
|
def test_manage_flag_still_returns_a_valid_image(client, db_session, monkeypatch):
|
|
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.get(PhotoWidgetConfig, widget.id).album_id = "album-1"
|
|
creds = claim_device(db_session, frame)
|
|
_mock_immich(monkeypatch)
|
|
|
|
plain = client.get(f"/frame/image?{creds}").content
|
|
with_manage = client.get(f"/frame/image?{creds}&manage=1").content
|
|
assert len(with_manage) == EXPECTED_BYTES
|
|
assert with_manage != plain # the manage-QR overlay actually got composited in
|
|
|
|
|
|
def test_two_widget_frame_composites_both_and_next_targets_calendar(client, db_session, monkeypatch):
|
|
"""A second frame (not frame #1) with two independent widgets -- a
|
|
calendar widget and a photo widget, addressed directly the way the
|
|
migration's calendar_photo_inlay backfill shapes a frame -- exercised
|
|
end to end over HTTP, not just via the migration's own unit tests."""
|
|
_mock_immich(monkeypatch)
|
|
|
|
frame = Frame(
|
|
name="Two Widget Frame", device_id="aabbccddeeff", device_token="devtok-2",
|
|
manage_token="mtok-2", orientation="landscape", created_at=time.time(),
|
|
)
|
|
db_session.add(frame)
|
|
db_session.flush()
|
|
|
|
cal_widget = Widget(frame_id=frame.id, widget_type="calendar", x=0, y=0, w=4, h=5,
|
|
sort_order=0, created_at=time.time())
|
|
photo_widget = Widget(frame_id=frame.id, widget_type="photos", x=4, y=0, w=4, h=5,
|
|
sort_order=1, created_at=time.time())
|
|
db_session.add_all([cal_widget, photo_widget])
|
|
db_session.flush()
|
|
db_session.add(CalendarWidgetConfig(widget_id=cal_widget.id, view="agenda", browse_offset=0))
|
|
db_session.add(PhotoWidgetConfig(widget_id=photo_widget.id, album_id="album-1"))
|
|
db_session.add_all([
|
|
FrameButtonAction(frame_id=frame.id, button="next", widget_id=cal_widget.id, action="advance"),
|
|
FrameButtonAction(frame_id=frame.id, button="back", widget_id=cal_widget.id, action="back"),
|
|
])
|
|
db_session.commit()
|
|
|
|
resp = client.get(f"/frame/image?id={frame.device_id}&token={frame.device_token}")
|
|
assert resp.status_code == 200
|
|
assert len(resp.content) == EXPECTED_BYTES
|
|
|
|
# NEXT is bound only to the calendar widget -- pressing it should
|
|
# move the calendar's browse_offset, not touch the photo widget.
|
|
resp = client.post(f"/frame/advance?id={frame.device_id}&token={frame.device_token}")
|
|
assert resp.status_code == 200
|
|
|
|
cal_cfg = db_session.get(CalendarWidgetConfig, cal_widget.id)
|
|
photo_cfg = db_session.get(PhotoWidgetConfig, photo_widget.id)
|
|
assert cal_cfg.browse_offset == 1
|
|
assert photo_cfg.current_asset_id == "" # untouched -- NEXT was never bound to it
|
|
|
|
|
|
def test_widgets_render_concurrently(client, db_session, monkeypatch):
|
|
"""Two independent, slow widgets on one frame should render in
|
|
roughly the time of the slowest one, not the sum -- the actual fix
|
|
for the "hold to cycle layouts times out and shows a false server-
|
|
failed status screen" bug: several network-backed widgets (photos,
|
|
weather, calendar) rendering one after another could push a single
|
|
/frame/* response past the firmware's fixed HTTP timeout even though
|
|
the server was simply still working."""
|
|
monkeypatch.setattr(widgets.photos, "immich_client_for", lambda frame: object())
|
|
monkeypatch.setattr(widgets.photos, "list_assets", lambda client, album_id: _ASSETS)
|
|
|
|
from PIL import Image
|
|
source = Image.new("RGB", (100, 80), (10, 20, 30))
|
|
|
|
def _slow_fetch(client, mode, asset_id):
|
|
time.sleep(0.25)
|
|
return source, None
|
|
|
|
monkeypatch.setattr(widgets.photos, "fetch_source_and_faces", _slow_fetch)
|
|
|
|
frame = Frame(
|
|
name="Concurrency Frame", device_id="112233445566", device_token="devtok-3",
|
|
manage_token="mtok-3", orientation="landscape", created_at=time.time(),
|
|
)
|
|
db_session.add(frame)
|
|
db_session.flush()
|
|
|
|
widget_a = Widget(frame_id=frame.id, widget_type="photos", x=0, y=0, w=4, h=5,
|
|
sort_order=0, created_at=time.time())
|
|
widget_b = Widget(frame_id=frame.id, widget_type="photos", x=4, y=0, w=4, h=5,
|
|
sort_order=1, created_at=time.time())
|
|
db_session.add_all([widget_a, widget_b])
|
|
db_session.flush()
|
|
db_session.add(PhotoWidgetConfig(widget_id=widget_a.id, album_id="album-a"))
|
|
db_session.add(PhotoWidgetConfig(widget_id=widget_b.id, album_id="album-b"))
|
|
db_session.commit()
|
|
|
|
start = time.monotonic()
|
|
resp = client.get(f"/frame/image?id={frame.device_id}&token={frame.device_token}")
|
|
elapsed = time.monotonic() - start
|
|
|
|
assert resp.status_code == 200
|
|
assert len(resp.content) == EXPECTED_BYTES
|
|
# Serial would be ~0.5s (2 x 0.25s); concurrent should land near 0.25s.
|
|
assert elapsed < 0.45, f"widgets rendered serially, not concurrently ({elapsed:.2f}s)"
|