From 8ea1c53ec3811f18ed3bc293c75079ba102ea2f4 Mon Sep 17 00:00:00 2001 From: Thomas Faour Date: Thu, 30 Jul 2026 22:18:43 +0000 Subject: [PATCH] Add experimental HTML/CSS "modern" render style for weather widget The weather widget's icons/layout are hand-drawn PIL primitives -- clean under quantization but flat, no gradients/shadows. Adds an opt-in render_style="modern" (current/daily modes only) that instead renders a Jinja2 template through a persistent headless-Chromium browser (app/html_render.py), following the approach of Tesserae, an open-source e-ink dashboard targeting this same panel family. Key design points: - The Chromium dependency (Playwright) is lazily imported only when a weather widget actually uses "modern" style, and the background browser itself only launches on first use -- every other widget type, and this one's own classic/hourly/multi_city paths, never pay for it. - No Frame-level dithering setting needed: html_render dithers its own rendered widget to exact palette colors (Bayer/ordered, not Floyd-Steinberg) before compositing, so the shared whole-canvas Floyd-Steinberg pass sees zero quantization error there and leaves it untouched -- same trick draw_text/hand-drawn icons already use. Floyd- Steinberg keeps working unchanged for photos and every other widget. - A "Load calibrated Spectra 6 preset" button in Advanced configuration offers a community-measured palette (data ported from paperlesspaper/epdoptimize, Apache 2.0) as an alternative starting point to the existing idealized DEFAULT_PALETTE_RGB -- fills the existing palette table, doesn't save by itself. Known open risk, not resolved here: a headless Chromium binary is far larger than the ~100MB single-layer limit that already forced this project's pip/npm installs into split layers, and (unlike those) is a single ~180MB file that can't be split across layers by ordinary Dockerfile restructuring. Flagged prominently in server/Dockerfile and docs/widgets.md -- treat this render style as experimental/local-only until that's resolved. --- docs/widgets.md | 19 + server/Dockerfile | 55 ++- server/app/html_render.py | 327 ++++++++++++++++++ server/app/image_pipeline.py | 21 ++ server/app/main.py | 18 +- server/app/migration.py | 17 + server/app/models.py | 8 +- server/app/routers/api_layouts.py | 2 +- server/app/routers/api_widgets.py | 20 +- server/app/routers/frame_pages.py | 2 + server/app/static/frame_config.js | 12 + server/app/static/widget_dialog_weather.js | 7 + .../app/templates/_widget_dialog_weather.html | 8 + server/app/templates/frame_config.html | 7 + .../widget_html/weather_current.html.jinja | 30 ++ .../widget_html/weather_daily.html.jinja | 51 +++ server/app/widgets/weather.py | 21 +- server/requirements.txt | 2 + server/tests/test_migrations.py | 1 + server/tests/test_saved_layouts.py | 2 +- .../test_widget_config_and_queue_endpoints.py | 29 ++ server/tests/test_widgets_weather.py | 80 ++++- 22 files changed, 717 insertions(+), 22 deletions(-) create mode 100644 server/app/html_render.py create mode 100644 server/app/templates/widget_html/weather_current.html.jinja create mode 100644 server/app/templates/widget_html/weather_daily.html.jinja diff --git a/docs/widgets.md b/docs/widgets.md index 17c8b28..5c59235 100644 --- a/docs/widgets.md +++ b/docs/widgets.md @@ -269,6 +269,25 @@ modes (`WeatherWidgetConfig.mode`, switchable in the widget's dialog like side -- the calendar widget's embedded strip, as a standalone widget's whole content instead of a strip above an agenda day. +**Render style** (`WeatherWidgetConfig.render_style`, `"classic"` default +| `"modern"`, experimental): `current`/`daily` only -- `hourly`/ +`multi_city` always render classic regardless of this setting. `modern` +draws the widget as an HTML/CSS card (Jinja2 templates under +`app/templates/widget_html/`) through a persistent headless Chromium +browser (`app/html_render.py`, Playwright) instead of `app/weather_render. +py`'s hand-drawn PIL primitives -- gradients/shadows/soft icon shading +PIL can't easily do. Its own `ordered_dither` (Bayer/ordered, not Floyd- +Steinberg) commits the rendered widget to exact palette colors *before* +compositing, so it's safe to mix with photo/other classic-rendered +widgets on the same frame without a Floyd-Steinberg seam at the boundary +(see that module's docstring for why ordered dithering doesn't have this +problem and Floyd-Steinberg does) -- no `Frame`-level dithering setting +was needed. Playwright/Chromium is a real, heavyweight runtime dependency +imported lazily only when a weather widget actually uses this style, and +its Docker packaging has a known likely-blocking image-size problem not +yet resolved (see `server/Dockerfile`'s own comment) -- treat this style +as unshipped/local-only until that's sorted out. + `current`/`hourly`/`daily` share one configured location (`city_label`/`city_latitude`/`city_longitude`, set via `POST .../ weather-location`, geocoded through `weather.geocode_city`); `multi_city` diff --git a/server/Dockerfile b/server/Dockerfile index 6dd26d0..43307b1 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -36,25 +36,62 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ tzdata fontconfig fonts-dejavu-core nodejs npm \ && rm -rf /var/lib/apt/lists/* +# EXPERIMENTAL, likely unmergeable as-is -- see below. System libs a +# headless Chromium needs (app/html_render.py, the weather widget's +# opt-in "modern" render style), trimmed from Playwright's own full +# `install-deps chromium` list to just what a headless (no Xvfb), +# Latin-text-plus-emoji use case needs: dropped xvfb (only needed for a +# *headed* browser) and the CJK/Cyrillic/Thai locale font packages +# (fonts-ipafont-gothic, fonts-wqy-zenhei, fonts-tlwg-loma-otf, +# xfonts-cyrillic, xfonts-scalable, fonts-freefont-ttf, fonts-unifont) -- +# fonts-noto-color-emoji is the one that actually matters here (real +# color emoji in the weather icons, vs. WeasyPrint/Pango's monochrome +# fallback glyphs in this feature's original spike). +RUN apt-get update && apt-get install -y --no-install-recommends \ + libasound2t64 libatk-bridge2.0-0t64 libatk1.0-0t64 libatspi2.0-0t64 \ + libcairo2 libcups2t64 libdbus-1-3 libdrm2 libgbm1 libglib2.0-0t64 \ + libnspr4 libnss3 libpango-1.0-0 libx11-6 libxcb1 libxcomposite1 \ + libxdamage1 libxext6 libxfixes3 libxkbcommon0 libxrandr2 \ + fonts-noto-color-emoji libfontconfig1 libfreetype6 fonts-liberation \ + && rm -rf /var/lib/apt/lists/* + COPY requirements.txt . # Split across several layers rather than one `pip install -r # requirements.txt` -- same Cloudflare single-blob/layer payload-size # limit as render-service's npm installs below. The single combined # layer was measured at ~113MB unpacked, over the limit on its own. -# Isolating the three largest packages gets every layer's unpacked size -# well clear of 100MB (sqlalchemy ~15MB, pillow ~19MB, pypdfium2 ~8MB, -# the remaining `-r requirements.txt` layer ~71MB). Each package -# version here still comes from requirements.txt (`pip install -r` for -# everything that doesn't need its own layer skips these three, since -# pip sees them already satisfied); the explicit versions below just -# control *when* each installs -- same "single source of truth, just -# splitting *when* it installs" tradeoff as the npm section's --no-save -# comment below. +# Isolating the largest packages gets every layer's unpacked size well +# clear of 100MB (sqlalchemy ~15MB, pillow ~19MB, pypdfium2 ~8MB, the +# remaining `-r requirements.txt` layer ~71MB). Each package version here +# still comes from requirements.txt (`pip install -r` for everything that +# doesn't need its own layer skips these, since pip sees them already +# satisfied); the explicit versions below just control *when* each +# installs -- same "single source of truth, just splitting *when* it +# installs" tradeoff as the npm section's --no-save comment below. RUN pip install --no-cache-dir sqlalchemy==2.0.51 RUN pip install --no-cache-dir pillow==12.3.0 RUN pip install --no-cache-dir pypdfium2==5.12.1 +RUN pip install --no-cache-dir playwright==1.61.0 RUN pip install --no-cache-dir -r requirements.txt +# KNOWN LIKELY BLOCKER, not resolved by pulling this into its own layer: +# `playwright install chromium-headless-shell` unpacks to ~262MB, and its +# single `chrome-headless-shell` binary alone (measured: 181MB) is one +# file -- unlike the pip/npm splits above (independently-installable +# smaller packages moved into their own layers), a single 181MB file +# can't be divided across multiple <100MB Docker layers by any ordinary +# COPY/RUN restructuring; the whole file lands in whichever layer's diff +# contains it. This almost certainly exceeds the same Cloudflare single- +# blob/layer limit that forced the pip/npm splits elsewhere in this file +# (see their comments) -- an actual push to this project's registry +# hasn't been attempted (would require pushing to `main`, which triggers +# deploy) to confirm, but there is no reason to expect a single 181MB +# blob to fit where combined ~113MB of many small wheels didn't. Needs a +# real resolution (a registry without this limit, hosting the browser +# binary outside the image, etc.) before this branch can actually ship -- +# tracked as open, not silently assumed away. +RUN playwright install chromium-headless-shell + # render-service/'s dependencies installed as several separate layers # rather than one `npm install` covering all of them -- a from-scratch # push of this image once hit Cloudflare's payload-size limit on a diff --git a/server/app/html_render.py b/server/app/html_render.py new file mode 100644 index 0000000..7c5f9e3 --- /dev/null +++ b/server/app/html_render.py @@ -0,0 +1,327 @@ +"""Experimental "modern" weather widget render style: Jinja2 + a +persistent headless Chromium browser (Playwright) instead of the hand- +drawn PIL primitives in weather_render.py -- see docs/widgets.md and the +`html-widget-render` branch's PR description for the design rationale +(gradients/shadows/soft shading that PIL can't easily do, at the cost of +a real browser-process dependency). + +Two things this module owns that nothing else in the codebase needed +before: + +1. A **persistent** background browser process. Widget rendering already + happens concurrently across a fresh `ThreadPoolExecutor` per frame + request (routers/device.py's _render_widgets) -- Playwright's sync + API is thread-affine (an object must be used from the thread that + created it), so a single browser object can't be handed across those + ad-hoc worker threads, and relaunching a full Chromium process on + every widget render would be real, avoidable latency. Fix: one + background thread runs its own persistent asyncio event loop hosting + one long-lived `Browser`, lazily started on first use (see start()) -- + not eagerly at server startup, so a deployment that never enables the + weather widget's "modern" style never launches Chromium at all and + never needs Playwright's browser binaries installed. main.py's + lifespan only wires up the *shutdown* half (stop()), so a clean + server restart doesn't leave an orphaned Chromium process behind if + this was ever actually used. render_html_to_image() is a plain sync + function any worker thread can call, bridging in via + `asyncio.run_coroutine_threadsafe` (the standard safe cross-thread + entry point into a *running* loop on another thread). + +2. **Per-region ordered (Bayer) dithering against the palette**, done + here rather than in the shared image_pipeline.py pipeline. + render_panel's whole-canvas single Floyd-Steinberg pass exists + because Floyd-Steinberg's error diffusion can't be split across + independently-quantized regions without a visible seam at the + boundary -- but that reasoning doesn't apply to ordered dithering, + which has no cross-pixel error term (each pixel's dither decision + only depends on its own position + color). So this module dithers its + own rendered widget to *already-exact* palette colors before + returning it; the later shared Floyd-Steinberg pass sees zero + quantization error there and leaves it untouched -- the same + "pre-commit to exact palette colors" trick image_pipeline.draw_text + and the hand-drawn weather icons already rely on, just reached a + different way. Floyd-Steinberg keeps working exactly as before for + photos and every other (classic-rendered) widget region. +""" + +from __future__ import annotations + +import asyncio +import io +import threading +from datetime import date +from pathlib import Path + +import numpy as np +from jinja2 import Environment, FileSystemLoader, select_autoescape +from PIL import Image + +from . import panel_style +from .image_pipeline import DEFAULT_PALETTE_RGB + +_TEMPLATE_DIR = Path(__file__).resolve().parent / "templates" / "widget_html" +_FONT_DIR = Path(__file__).resolve().parent / "fonts" + +_jinja_env = Environment( + loader=FileSystemLoader(str(_TEMPLATE_DIR)), + autoescape=select_autoescape(["html", "jinja"]), +) + +CATEGORY_EMOJI = { + "clear": "☀️", + "partly_cloudy": "⛅", + "cloudy": "☁️", + "fog": "\U0001f32b️", + "rain": "\U0001f327️", + "snow": "❄️", + "thunderstorm": "⛈️", +} + +# ACCENT_START/END: a fixed blue gradient pair for the "modern" style's +# card header -- deliberately not routed through panel_style.theme_color +# (unlike every classic-rendered widget's chrome), since the whole point +# of this style is the gradient look ordered_dither below then commits +# to exact palette colors anyway; which literal hex this starts from +# doesn't matter to the end result the way it would for a flat PIL fill. +ACCENT_START = "#1c4fd6" +ACCENT_END = "#6fa8ff" + + +# --- Persistent background browser ------------------------------------- + +_loop: asyncio.AbstractEventLoop | None = None +_loop_thread: threading.Thread | None = None +_browser = None +_playwright_cm = None +_start_lock = threading.Lock() + + +async def _launch_browser() -> None: + global _browser, _playwright_cm + from playwright.async_api import async_playwright + + _playwright_cm = async_playwright() + playwright = await _playwright_cm.__aenter__() + _browser = await playwright.chromium.launch() + + +async def _close_browser() -> None: + global _browser, _playwright_cm + if _browser is not None: + await _browser.close() + _browser = None + if _playwright_cm is not None: + await _playwright_cm.__aexit__(None, None, None) + _playwright_cm = None + + +def start() -> None: + """Launches the background event loop + persistent Chromium browser, + if not already running. Called lazily by render_html_to_image on + first use (not from main.py's lifespan -- see module docstring for + why this must stay opt-in) -- exposed directly too, for tests that + want to control startup explicitly. Idempotent -- a second call + while already started is a no-op.""" + global _loop, _loop_thread + if _loop is not None: + return + ready = threading.Event() + + def _run() -> None: + global _loop + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + _loop = loop + ready.set() + loop.run_forever() + + _loop_thread = threading.Thread(target=_run, daemon=True, name="html-render-loop") + _loop_thread.start() + ready.wait() + asyncio.run_coroutine_threadsafe(_launch_browser(), _loop).result() + + +def stop() -> None: + """Closes the browser and stops the background loop -- called from + main.py's lifespan shutdown so a server restart never leaves an + orphaned Chromium process behind. No-op if start() was never called + (the common case: most deployments never enable "modern" style).""" + global _loop, _loop_thread + if _loop is None: + return + asyncio.run_coroutine_threadsafe(_close_browser(), _loop).result() + _loop.call_soon_threadsafe(_loop.stop) + _loop_thread.join(timeout=5) + _loop = None + _loop_thread = None + + +async def _screenshot(html: str, target_w: int, target_h: int) -> bytes: + page = await _browser.new_page(viewport={"width": target_w, "height": target_h}, device_scale_factor=1) + try: + await page.set_content(html, wait_until="networkidle") + return await page.screenshot() + finally: + await page.close() + + +def render_html_to_image(html: str, target_w: int, target_h: int) -> Image.Image: + """Renders `html` (already sized to target_w x target_h via its own + + +
+
{{ emoji }}
+
{{ temp }}°{{ unit_suffix }}
+ {% if city_label %}
{{ city_label }}
{% endif %} +
+ diff --git a/server/app/templates/widget_html/weather_daily.html.jinja b/server/app/templates/widget_html/weather_daily.html.jinja new file mode 100644 index 0000000..a9b2306 --- /dev/null +++ b/server/app/templates/widget_html/weather_daily.html.jinja @@ -0,0 +1,51 @@ + + + +
+ {% if city_label %}
{{ city_label }}
{% endif %} +
+ {% for d in days %} +
+
{{ d.label }}
+
{{ d.emoji }}
+
{{ d.high }}°/{{ d.low }}°{{ unit_suffix }}
+
+ {% endfor %} +
+
+ diff --git a/server/app/widgets/weather.py b/server/app/widgets/weather.py index fb7573e..f02ea50 100644 --- a/server/app/widgets/weather.py +++ b/server/app/widgets/weather.py @@ -1,9 +1,12 @@ """Weather widget: one of four display modes (see models. WeatherWidgetConfig) backed by a pluggable provider (app/weather/'s PROVIDERS registry -- Open-Meteo or NWS) and drawn by app/weather_render. -py's build() dispatch. No real "next"/"back" concept (same as -whiteboard) -- a single "check now" action forces a re-fetch bypassing -the normal throttle.""" +py's build() dispatch -- or, for "current"/"daily" modes with +render_style="modern", by app/html_render.py's Jinja2/headless-Chromium +renderer instead (experimental; hourly/multi_city always render classic +regardless of render_style, see html_render's module docstring). No real +"next"/"back" concept (same as whiteboard) -- a single "check now" +action forces a re-fetch bypassing the normal throttle.""" from __future__ import annotations @@ -27,6 +30,18 @@ def render(db: Session, frame: Frame, widget: Widget, target_w: int, target_h: i data = get_or_refresh_weather_widget_data(db, frame, widget) if data is None: return placeholder_image(target_w, target_h, ["Weather widget", "not configured yet"]) + + if cfg.render_style == "modern" and cfg.mode in ("current", "daily"): + # Local import: html_render pulls in Playwright, a real headless- + # Chromium dependency -- every other widget type, and this one's + # own classic/hourly/multi_city paths, should never pay for it + # (same reasoning as image_pipeline.render_placeholder's local + # `import qrcode`). + from .. import html_render + + return html_render.build(cfg.mode, data, target_w, target_h, frame.palette_rgb, cfg.units, + city_label=cfg.city_label or "") + return weather_render.build( cfg.mode, data, target_w, target_h, frame.palette_rgb, cfg.units, city_label=cfg.city_label or "", interval_hours=cfg.hourly_interval_hours, diff --git a/server/requirements.txt b/server/requirements.txt index 0b675fb..ea4a1fd 100644 --- a/server/requirements.txt +++ b/server/requirements.txt @@ -11,3 +11,5 @@ icalendar==7.2.2 recurring-ical-events==3.8.2 caldav==3.2.1 pypdfium2==5.12.1 +playwright==1.61.0 +numpy==2.5.1 diff --git a/server/tests/test_migrations.py b/server/tests/test_migrations.py index adf81c4..f481766 100644 --- a/server/tests/test_migrations.py +++ b/server/tests/test_migrations.py @@ -93,6 +93,7 @@ def test_expected_columns_exist_on_current_schema(): assert "ix_frame_button_actions_widget_button" in button_action_indexes # migration 28 assert {"hold_duration_ms", "next_hold_action", "back_hold_action", "last_cycled_layout_id"} <= frame_columns assert {"last_displayed_image", "last_displayed_at"} <= frame_columns # migration 30 + assert "render_style" in weather_widget_columns # migration 31 # --- widget system backfill (migration 16 + _ensure_widgets_backfilled) --- diff --git a/server/tests/test_saved_layouts.py b/server/tests/test_saved_layouts.py index 2bb40d8..373ba17 100644 --- a/server/tests/test_saved_layouts.py +++ b/server/tests/test_saved_layouts.py @@ -125,7 +125,7 @@ def test_save_and_apply_round_trip_weather_settings(client, db_session): assert snap.config == { "mode": "daily", "provider": "nws", "units": "celsius", "city_label": "Boston, MA", "city_latitude": 42.36, "city_longitude": -71.06, "hourly_interval_hours": 6, "daily_days": 7, - "cities": None, + "cities": None, "render_style": "classic", } client.delete("/api/frames/1/widgets", headers=csrf_headers(client)) diff --git a/server/tests/test_widget_config_and_queue_endpoints.py b/server/tests/test_widget_config_and_queue_endpoints.py index cbe14eb..331b9ee 100644 --- a/server/tests/test_widget_config_and_queue_endpoints.py +++ b/server/tests/test_widget_config_and_queue_endpoints.py @@ -243,6 +243,35 @@ def test_config_save_switching_mode_clears_the_now_incompatible_cache(client, db assert resp.status_code == 200, resp.text +def test_preview_weather_honors_modern_render_style(client, db_session, monkeypatch): + """Regression test: api_widget_preview_weather originally called + weather_render.render_weather_preview_png directly, unconditionally -- + the dialog's own live preview never reflected render_style="modern" at + all, even though the real device-facing render (widgets/weather.py's + render()) did. Route through html_render instead for modern/current or + modern/daily, same as the device path -- assert it's actually reached, + not just that the request 200s (it would 200 either way if this + silently fell back to classic).""" + client.post("/setup", data={"username": "alice", "password": "hunter22"}) + widget = _add_weather_widget(db_session, mode="current", render_style="modern") + monkeypatch.setattr( + "app.routers.api_widgets.get_or_refresh_weather_widget_data", + lambda db, frame, widget, force=False: {"temp": 72.0, "category": "clear"}, + ) + calls = [] + + def _fake_render_html_to_image(html, target_w, target_h): + calls.append((target_w, target_h)) + return Image.new("RGB", (target_w, target_h), (255, 255, 255)) + + monkeypatch.setattr("app.html_render.render_html_to_image", _fake_render_html_to_image) + + resp = client.get(f"/api/frames/1/widgets/{widget.id}/preview/weather") + assert resp.status_code == 200, resp.text + assert resp.headers["content-type"] == "image/png" + assert calls, "html_render.render_html_to_image was never called -- preview endpoint didn't honor render_style" + + def test_config_save_truncates_an_overlong_tasks_name(client, db_session): client.post("/setup", data={"username": "alice", "password": "hunter22"}) widget = _add_tasks_widget(db_session) diff --git a/server/tests/test_widgets_weather.py b/server/tests/test_widgets_weather.py index bf220c8..0676339 100644 --- a/server/tests/test_widgets_weather.py +++ b/server/tests/test_widgets_weather.py @@ -11,7 +11,9 @@ from __future__ import annotations import time -from app import grid, widgets +from PIL import Image + +from app import grid, html_render, widgets from app.models import Frame, WeatherWidgetConfig, Widget @@ -109,3 +111,79 @@ def test_check_now_forces_a_refetch(db_session, monkeypatch): def test_action_labels(): assert set(widgets.weather.ACTIONS.keys()) == {"check_now"} assert widgets.weather.ACTION_LABELS == {"check_now": "Check for updates"} + + +# --- render_style="modern" (app/html_render.py) ------------------------- +# No real browser here -- html_render.render_html_to_image is monkeypatched +# to a stub, so these tests exercise weather.py's dispatch + html_render's +# own template-rendering/ordered_dither logic, not Playwright/Chromium +# itself (that needs a real browser install -- see the run-server-driven +# manual verification these tests don't replace). + +def _stub_render_html_to_image(monkeypatch, fill=(10, 20, 200)): + def _stub(html, target_w, target_h): + return Image.new("RGB", (target_w, target_h), fill) + + monkeypatch.setattr(html_render, "render_html_to_image", _stub) + + +def test_render_modern_current_mode(db_session, monkeypatch): + frame, widget = _make_widget(db_session, mode="current", city_label="Portland", render_style="modern") + monkeypatch.setattr(widgets.weather, "get_or_refresh_weather_widget_data", + lambda db, frame, widget: {"temp": 72.0, "category": "clear"}) + _stub_render_html_to_image(monkeypatch) + + img = widgets.weather.render(db_session, frame, widget, 300, 200) + assert img.size == (300, 200) + assert img.mode == "RGB" + + +def test_render_modern_daily_mode(db_session, monkeypatch): + frame, widget = _make_widget(db_session, mode="daily", city_label="Denver", daily_days=5, render_style="modern") + daily = {f"2026-07-{27 + i}": {"high": 70 + i, "low": 50 + i, "category": "clear"} for i in range(5)} + monkeypatch.setattr(widgets.weather, "get_or_refresh_weather_widget_data", lambda db, frame, widget: daily) + _stub_render_html_to_image(monkeypatch) + + img = widgets.weather.render(db_session, frame, widget, 400, 300) + assert img.size == (400, 300) + assert img.mode == "RGB" + + +def test_render_modern_ordered_dither_is_exact_palette(db_session, monkeypatch): + """The whole point of doing ordered dithering inside html_render (see + its module docstring) is that its output is already exact palette + colors before the shared whole-canvas Floyd-Steinberg pass ever sees + it -- assert that directly, not just "an image came back".""" + frame, widget = _make_widget(db_session, mode="current", render_style="modern") + monkeypatch.setattr(widgets.weather, "get_or_refresh_weather_widget_data", + lambda db, frame, widget: {"temp": 72.0, "category": "clear"}) + # A mid-gray fill is nowhere near any of DEFAULT_PALETTE_RGB's 6 exact + # colors -- if ordered_dither's nearest-palette-match ran, every pixel + # must land on one of them regardless. + _stub_render_html_to_image(monkeypatch, fill=(128, 128, 128)) + + img = widgets.weather.render(db_session, frame, widget, 120, 100) + from app.image_pipeline import DEFAULT_PALETTE_RGB + + palette = set(DEFAULT_PALETTE_RGB) + assert set(img.getdata()) <= palette + + +def test_render_modern_falls_back_to_classic_for_hourly_and_multi_city(db_session, monkeypatch): + """hourly/multi_city have no "modern" template yet (see html_render's + module docstring) -- render_style="modern" on those modes must still + produce the classic PIL render, not error or silently do nothing. + Deliberately does NOT stub html_render, so this also proves the + classic path never imports it for these modes.""" + frame, widget = _make_widget(db_session, mode="multi_city", render_style="modern") + cities = [{"label": "Portland, Oregon, United States", "high": 75, "low": 55, "category": "clear"}] + monkeypatch.setattr(widgets.weather, "get_or_refresh_weather_widget_data", lambda db, frame, widget: cities) + + img = widgets.weather.render(db_session, frame, widget, 400, 300) + assert img.size == (400, 300) + + +def test_render_style_default_is_classic(db_session, monkeypatch): + frame, widget = _make_widget(db_session, mode="current") + cfg = db_session.get(WeatherWidgetConfig, widget.id) + assert cfg.render_style == "classic"