New app/widgets/ package (photos.py, calendar.py, whiteboard.py, plus the WIDGET_TYPES registry) -- the widget-system analogue of routers/device.py's old RENDERERS/ADVANCE_RENDERERS/BACK_RENDERERS, generalized from "one mode owns the whole panel" to "each widget renders into its own region and responds to named button actions." Each module exposes render(db, frame, widget, target_w, target_h) -> Image.Image (never raises -- a widget's own fetch hiccup falls back to a small placeholder rather than taking the whole panel's render down) and an ACTIONS registry for NEXT/BACK button assignment. Supporting changes needed to give the widget modules something to call, all mechanical/behavior-preserving for every existing caller: - image_pipeline.py: render_panel(regions, ...) generalizes render_frame's tail (paste, enhance once, overlay once, quantize once, pack once) from one photo to N regions -- not a restructuring, since calendar mode's photo-inlay feature already pastes a second composed image onto the canvas before that single shared pipeline runs. - photo_queue.py: advance_forced/back_forced/remove_from_rotation/ get_current take an explicit `frame` param now that `cfg` won't always be the Frame itself once photo-queue state moves to PhotoWidgetConfig -- caught a real latent bug while doing this: get_current was reading refresh_interval_s off `cfg`, but that's a frame-level wake-cadence setting, not something that becomes per-widget, so it now reads that off `frame` explicitly instead. - routers/common.py: list_assets/fetch_source_and_faces take album_id/ display_mode directly instead of a whole Frame (both only ever read that one attribute off it); new get_or_refresh_*_for_widget siblings of the existing calendar/weather/tasks/whiteboard cache helpers, read/ writing the new per-widget config tables -- the Frame-scoped originals are untouched and still what routers/device.py's actual dispatch calls until the Phase 2 cutover. 26 new tests (95 total): render_panel size/placement/orientation coverage, and per-widget-type render/action tests (unconfigured -> placeholder, a fetch failure -> placeholder not a crash, actions mutate the right state). Full suite passes; diff-reviewed to confirm device.py's actual RENDERERS dispatch and the old Frame-scoped get_or_refresh_* bodies are unchanged, so this is safe to deploy on its own despite being step 1 of a two-step cutover (see the project plan on why the *next* step, not this one, has to ship atomically).
146 lines
6.3 KiB
Python
146 lines
6.3 KiB
Python
"""Every renderer that produces a device-facing frame must return
|
|
exactly EPD_WIDTH*EPD_HEIGHT/2 bytes (the panel's packed 2px/byte
|
|
format) -- firmware writes this straight to the display with no length
|
|
checking of its own, so a renderer that's off by even one byte is a
|
|
silent on-device corruption bug, not a clean error. This is a cheap,
|
|
high-value regression guard: pure PIL rendering, no DB/HTTP/Node."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
from PIL import Image
|
|
|
|
from app.calendar_render import CALENDAR_VIEWS, render_calendar
|
|
from app.grid import cell_to_pixels, full_panel_rect, grid_dims
|
|
from app.image_pipeline import EPD_HEIGHT, EPD_WIDTH, render_panel, render_placeholder
|
|
|
|
EXPECTED_BYTES = EPD_WIDTH * EPD_HEIGHT // 2
|
|
ORIENTATIONS = ["landscape", "landscape_flipped", "portrait", "portrait_flipped"]
|
|
|
|
_SAMPLE_EVENTS = [
|
|
{
|
|
"summary": "Dentist", "start": "2026-08-01T14:00:00+00:00", "end": "2026-08-01T15:00:00+00:00",
|
|
"all_day": False, "sources": [{"owner_display_name": "Alice", "color_index": None}],
|
|
},
|
|
{
|
|
"summary": "Team Offsite", "start": "2026-08-03T00:00:00", "end": "2026-08-04T00:00:00",
|
|
"all_day": True, "sources": [{"owner_display_name": "Bob", "color_index": 2}],
|
|
},
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize("orientation", ORIENTATIONS)
|
|
def test_placeholder_render_size(orientation):
|
|
data = render_placeholder(["Not configured yet"], orientation=orientation)
|
|
assert len(data) == EXPECTED_BYTES
|
|
|
|
|
|
@pytest.mark.parametrize("view", CALENDAR_VIEWS)
|
|
def test_calendar_render_size_across_views(view):
|
|
data = render_calendar(_SAMPLE_EVENTS, view, browse_offset=0, orientation="landscape",
|
|
palette_rgb=None, timezone="UTC")
|
|
assert len(data) == EXPECTED_BYTES
|
|
|
|
|
|
@pytest.mark.parametrize("orientation", ORIENTATIONS)
|
|
def test_calendar_render_size_across_orientations(orientation):
|
|
data = render_calendar(_SAMPLE_EVENTS, "agenda", browse_offset=0, orientation=orientation,
|
|
palette_rgb=None, timezone="UTC")
|
|
assert len(data) == EXPECTED_BYTES
|
|
|
|
|
|
def test_calendar_render_size_empty_events():
|
|
data = render_calendar([], "week", browse_offset=0, orientation="landscape",
|
|
palette_rgb=None, timezone="UTC")
|
|
assert len(data) == EXPECTED_BYTES
|
|
|
|
|
|
def test_calendar_render_size_with_fetch_summary_and_tasks():
|
|
tasks = [{"summary": "Buy milk", "completed": False}, {"summary": "Walk the dog", "completed": True}]
|
|
data = render_calendar(_SAMPLE_EVENTS, "week", browse_offset=0, orientation="landscape",
|
|
palette_rgb=None, timezone="UTC", fetch_summary="1 of 2 calendars unavailable",
|
|
week_days=5, week_layout="vertical", tasks=tasks)
|
|
assert len(data) == EXPECTED_BYTES
|
|
|
|
|
|
def test_calendar_render_size_with_week_start_offset():
|
|
data = render_calendar(_SAMPLE_EVENTS, "week", browse_offset=0, orientation="landscape",
|
|
palette_rgb=None, timezone="UTC", week_days=3, week_start_offset=2)
|
|
assert len(data) == EXPECTED_BYTES
|
|
|
|
|
|
# --- render_panel (the widget-system compositor) ---
|
|
|
|
|
|
@pytest.mark.parametrize("orientation", ORIENTATIONS)
|
|
def test_render_panel_size_single_full_panel_region(orientation):
|
|
from app.image_pipeline import logical_render_size
|
|
|
|
w, h = logical_render_size(orientation)
|
|
region = Image.new("RGB", (w, h), (200, 0, 0))
|
|
data = render_panel([((0, 0, w, h), region)], orientation=orientation)
|
|
assert len(data) == EXPECTED_BYTES
|
|
|
|
|
|
def test_render_panel_size_multiple_non_overlapping_regions():
|
|
cols, rows = grid_dims("landscape")
|
|
left_px = cell_to_pixels("landscape", EPD_WIDTH, EPD_HEIGHT, (0, 0, cols // 2, rows))
|
|
right_px = cell_to_pixels("landscape", EPD_WIDTH, EPD_HEIGHT, (cols // 2, 0, cols - cols // 2, rows))
|
|
regions = [
|
|
(left_px, Image.new("RGB", left_px[2:], (200, 0, 0))),
|
|
(right_px, Image.new("RGB", right_px[2:], (0, 0, 200))),
|
|
]
|
|
data = render_panel(regions, orientation="landscape")
|
|
assert len(data) == EXPECTED_BYTES
|
|
|
|
|
|
def test_render_panel_empty_region_list_is_blank_but_correctly_sized():
|
|
"""No widgets on a frame yet (or all somehow filtered out) shouldn't
|
|
crash the compositor -- just a blank panel, same size invariant."""
|
|
data = render_panel([], orientation="landscape")
|
|
assert len(data) == EXPECTED_BYTES
|
|
|
|
|
|
def test_render_panel_pastes_regions_at_the_right_place():
|
|
"""Not just a size check -- confirms two regions actually land where
|
|
their rects say, not just that *something* the right size comes out."""
|
|
cols, rows = grid_dims("landscape")
|
|
left_rect = (0, 0, cols // 2, rows)
|
|
right_rect = (cols // 2, 0, cols - cols // 2, rows)
|
|
left_px = cell_to_pixels("landscape", EPD_WIDTH, EPD_HEIGHT, left_rect)
|
|
right_px = cell_to_pixels("landscape", EPD_WIDTH, EPD_HEIGHT, right_rect)
|
|
# Pure red vs pure blue, both already-palette colors, dither_strength=0
|
|
# so quantization can't introduce any blending/dithering noise --
|
|
# every pixel on each side should land on exactly the color it started as.
|
|
regions = [
|
|
(left_px, Image.new("RGB", left_px[2:], (255, 0, 0))),
|
|
(right_px, Image.new("RGB", right_px[2:], (0, 0, 255))),
|
|
]
|
|
data = render_panel(regions, orientation="landscape", dither_strength=0.0)
|
|
|
|
from app.image_pipeline import PANEL_CODES
|
|
|
|
def code_at(x, y):
|
|
i = (y * EPD_WIDTH + x) // 2
|
|
byte = data[i]
|
|
return (byte >> 4) if x % 2 == 0 else (byte & 0x0F)
|
|
|
|
red_code = PANEL_CODES[3] # DEFAULT_PALETTE_RGB index 3 = RED
|
|
blue_code = PANEL_CODES[4] # index 4 = BLUE
|
|
# Sample well inside each half, away from the boundary, at a y
|
|
# comfortably inside the panel.
|
|
assert code_at(50, 240) == red_code
|
|
assert code_at(750, 240) == blue_code
|
|
|
|
|
|
def test_render_panel_backfilled_full_panel_widget_matches_grid_full_panel_rect():
|
|
"""Sanity-links app.grid's full_panel_rect (what the migration backfill
|
|
uses for the single auto-migrated widget) to render_panel's own size
|
|
invariant, so a mismatch between the two would fail loudly here."""
|
|
rect = full_panel_rect("landscape")
|
|
px = cell_to_pixels("landscape", EPD_WIDTH, EPD_HEIGHT, rect)
|
|
assert px == (0, 0, EPD_WIDTH, EPD_HEIGHT)
|
|
region = Image.new("RGB", px[2:], (10, 20, 30))
|
|
data = render_panel([(px, region)], orientation="landscape")
|
|
assert len(data) == EXPECTED_BYTES
|