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