"""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 # --- a second, synthetic panel size (proves the packing path is genuinely # resolution-agnostic, ahead of the real 13.3" panel's numbers existing -- # see image_pipeline._transpose_and_pack, which derives its output size # from the quantized image itself rather than a hardcoded EPD_WIDTH/ # EPD_HEIGHT global) --- @pytest.fixture def synthetic_panel(monkeypatch): """Registers a second PANEL_SPECS entry, a different size than the real 7.3" panel, without needing the real 13.3" panel's confirmed resolution to exist yet.""" from app import image_pipeline monkeypatch.setitem(image_pipeline.PANEL_SPECS, "test_panel", (600, 400)) return "test_panel", 600, 400 @pytest.mark.parametrize("orientation", ORIENTATIONS) def test_render_panel_size_for_a_synthetic_second_panel_type(synthetic_panel, orientation): from app.image_pipeline import logical_render_size panel_type, panel_w, panel_h = synthetic_panel w, h = logical_render_size(orientation, panel_w, panel_h) region = Image.new("RGB", (w, h), (200, 0, 0)) data = render_panel([((0, 0, w, h), region)], orientation=orientation, panel_type=panel_type) assert len(data) == panel_w * panel_h // 2 @pytest.mark.parametrize("orientation", ORIENTATIONS) def test_render_placeholder_size_for_a_synthetic_second_panel_type(synthetic_panel, orientation): panel_type, panel_w, panel_h = synthetic_panel data = render_placeholder(["Not configured yet"], orientation=orientation, panel_type=panel_type) assert len(data) == panel_w * panel_h // 2 def test_render_panel_default_panel_type_is_unaffected_by_a_new_registry_entry(synthetic_panel): """A second PANEL_SPECS entry existing must never change what an ordinary (no panel_type passed) render produces -- every existing 7.3" frame's output stays byte-identical regardless of what other panels get registered.""" data = render_placeholder(["Not configured yet"], orientation="landscape") assert len(data) == EXPECTED_BYTES def test_panel_size_falls_back_to_the_original_panel_for_unknown_types(): from app.image_pipeline import panel_size assert panel_size("nonexistent") == (EPD_WIDTH, EPD_HEIGHT) assert panel_size("") == (EPD_WIDTH, EPD_HEIGHT) # --- the real (not synthetic) 13.3" Spectra 6 / EE02 panel geometry, # confirmed from Waveshare's/Seeed's public product pages -- the vendor # init/LUT/refresh sequence firmware-side is still unconfirmed (see # image_pipeline.PANEL_SPECS's own comment), but the geometry itself is # real, not a placeholder, so it gets the same coverage as the 7.3" panel # rather than just the synthetic-panel tests above. --- @pytest.mark.parametrize("orientation", ORIENTATIONS) def test_render_panel_size_for_the_real_13in3_panel(orientation): from app.image_pipeline import PANEL_SPECS, logical_render_size panel_w, panel_h = PANEL_SPECS["epd13in3e"] w, h = logical_render_size(orientation, panel_w, panel_h) region = Image.new("RGB", (w, h), (200, 0, 0)) data = render_panel([((0, 0, w, h), region)], orientation=orientation, panel_type="epd13in3e") assert len(data) == panel_w * panel_h // 2 @pytest.mark.parametrize("orientation", ORIENTATIONS) def test_render_placeholder_size_for_the_real_13in3_panel(orientation): from app.image_pipeline import PANEL_SPECS panel_w, panel_h = PANEL_SPECS["epd13in3e"] data = render_placeholder(["Not configured yet"], orientation=orientation, panel_type="epd13in3e") assert len(data) == panel_w * panel_h // 2