Files
espresso_frame/server/tests/test_render_size_invariants.py
T
Thomas Faour b5c52004c8
Build and push server image / test (push) Successful in 24s
Build and push server image / build-and-push (push) Successful in 2m1s
Build and push server image / deploy (push) Successful in 56s
Split the tasks feature out of the calendar widget into its own widget type
Task lists used to be a week-view-only sub-feature bolted onto calendar
widgets (CalendarWidgetConfig.tasks_*), so a task list could only exist
tied to a calendar's view and only inside its footprint. Tasks are now
a standalone widget type (TaskWidgetConfig, app/widgets/tasks.py) that
can be placed and sized independently, same as photos/calendar/
whiteboard -- no separate "enabled" flag either, since being on the
grid at all is the on/off switch, matching every other widget type.

Migration 17 creates task_widget_configs, extracts any existing
calendar widget's configured task source into a new sibling tasks
widget (auto-placed in open grid space, source dropped+logged if truly
none is left), then drops calendar_widget_configs' now-dead tasks_*
columns in the same migration -- this project's usual same-migration-
drop convention. Also handles the rarer case of a database jumping
straight from before the widget system existed to after this split in
one boot, via the legacy Frame.calendar_tasks_* columns.

Verified live in the browser at desktop and mobile widths: adding a
Tasks widget, its own dialog (task-list source picker + preview), and
confirming the calendar widget's dialog no longer mentions tasks at
all. Full test suite (180 tests, including new coverage for the widget
render/actions, the migration's data-extraction path, and the
permission-boundary shape for tasks-source) passes.
2026-07-25 02:11:45 +00:00

164 lines
6.8 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, render_tasks
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():
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")
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
# --- tasks widget (split out of the calendar widget's old week-view-only task list) ---
_SAMPLE_TASKS = [
{"summary": "Buy milk", "due": "2026-08-02"},
{"summary": "Walk the dog", "due": None},
]
@pytest.mark.parametrize("orientation", ORIENTATIONS)
def test_tasks_render_size_across_orientations(orientation):
data = render_tasks(_SAMPLE_TASKS, orientation=orientation, palette_rgb=None)
assert len(data) == EXPECTED_BYTES
def test_tasks_render_size_empty():
data = render_tasks([], orientation="landscape", palette_rgb=None)
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