"""app.widgets.weather -- unit-level, no HTTP: constructs Widget/ WeatherWidgetConfig rows directly and monkeypatches the underlying fetch call (get_or_refresh_weather_widget_data, whose own throttle/provider- dispatch logic is covered by test_weather_providers.py and exercised at the HTTP layer in test_widget_config_and_queue_endpoints.py) -- this file is about the widget wiring itself: does render() produce a correctly- sized image for each of the four modes, does it fall back to a placeholder when unconfigured, does check_now force a refetch.""" from __future__ import annotations import time from PIL import Image from app import grid, html_render, widgets from app.models import Frame, WeatherWidgetConfig, Widget def _make_widget(db_session, **cfg_kwargs) -> tuple[Frame, Widget]: frame = db_session.get(Frame, 1) widget = Widget(frame_id=frame.id, widget_type="weather", x=0, y=0, w=2, h=2, sort_order=0, created_at=time.time()) db_session.add(widget) db_session.flush() db_session.add(WeatherWidgetConfig(widget_id=widget.id, **cfg_kwargs)) db_session.commit() return frame, widget def test_render_falls_back_to_placeholder_when_not_configured(db_session, monkeypatch): frame, widget = _make_widget(db_session, mode="current") monkeypatch.setattr(widgets.weather, "get_or_refresh_weather_widget_data", lambda db, frame, widget: None) img = widgets.weather.render(db_session, frame, widget, 300, 200) assert img.size == (300, 200) assert img.mode == "RGB" def test_render_current_mode(db_session, monkeypatch): frame, widget = _make_widget(db_session, mode="current", city_label="Portland") monkeypatch.setattr(widgets.weather, "get_or_refresh_weather_widget_data", lambda db, frame, widget: {"temp": 72.0, "category": "clear"}) img = widgets.weather.render(db_session, frame, widget, 300, 200) assert img.size == (300, 200) assert img.mode == "RGB" def test_render_hourly_mode(db_session, monkeypatch): frame, widget = _make_widget(db_session, mode="hourly", city_label="Seattle", hourly_interval_hours=4) hourly = [{"time": f"2026-07-27T{h:02d}:00", "temp": 60 + h, "category": "rain"} for h in range(24)] monkeypatch.setattr(widgets.weather, "get_or_refresh_weather_widget_data", lambda db, frame, widget: hourly) img = widgets.weather.render(db_session, frame, widget, 400, 300) assert img.size == (400, 300) def test_render_daily_mode(db_session, monkeypatch): frame, widget = _make_widget(db_session, mode="daily", city_label="Denver", daily_days=5) 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) img = widgets.weather.render(db_session, frame, widget, 400, 300) assert img.size == (400, 300) def test_render_multi_city_mode(db_session, monkeypatch): """cached entries carry the full disambiguated geocoder label (see get_or_refresh_weather_widget_data/weather.geocode_city) -- render() must still produce a correctly-sized image (weather_render. build_multi_city shortens to just the city name for display, same as calendar_render.py's _weather_for_day).""" frame, widget = _make_widget(db_session, mode="multi_city") cities = [ {"label": "Portland, Oregon, United States", "high": 75, "low": 55, "category": "clear"}, {"label": "Seattle, Washington, United States", "high": 65, "low": 50, "category": "rain"}, ] 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_at_minimum_grid_footprint(db_session, monkeypatch): """grid.MIN_FOOTPRINT["weather"] is (2, 2) cells -- on an 8x5 grid against a full 800x480 panel that's a 200x192 box, the smallest a weather widget can actually be placed at.""" assert grid.MIN_FOOTPRINT["weather"] == (2, 2) frame, widget = _make_widget(db_session, mode="current") monkeypatch.setattr(widgets.weather, "get_or_refresh_weather_widget_data", lambda db, frame, widget: {"temp": 72.0, "category": "clear"}) img = widgets.weather.render(db_session, frame, widget, 200, 192) assert img.size == (200, 192) def test_check_now_forces_a_refetch(db_session, monkeypatch): frame, widget = _make_widget(db_session, mode="current") calls = [] def _fake_refresh(db, frame, widget, force=False): calls.append(force) return {"temp": 70.0, "category": "clear"} monkeypatch.setattr(widgets.weather, "get_or_refresh_weather_widget_data", _fake_refresh) widgets.weather.ACTIONS["check_now"](db_session, frame, widget) assert calls == [True] 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" def test_render_modern_daily_threads_frame_theme_through_to_resolve_theme(db_session, monkeypatch): """widgets/weather.py's render() must pass frame.theme all the way into html_render.build_daily's theme resolution -- spies on theme_tokens.resolve_theme (still delegating to the real implementation) rather than diffing final pixels, since the stubbed render_html_to_image below never actually executes the template's CSS (that's the whole point of stubbing out Chromium), so a theme's accent color has nothing to visibly change in the fake screenshot.""" from app import theme_tokens calls = [] real_resolve = theme_tokens.resolve_theme def _spy(theme_name, widget_kind, palette_rgb): calls.append((theme_name, widget_kind)) return real_resolve(theme_name, widget_kind, palette_rgb) monkeypatch.setattr(theme_tokens, "resolve_theme", _spy) monkeypatch.setattr(html_render, "theme_tokens", theme_tokens) daily = {"2026-07-31": {"high": 75, "low": 55, "category": "clear"}} monkeypatch.setattr(widgets.weather, "get_or_refresh_weather_widget_data", lambda db, frame, widget: daily) _stub_render_html_to_image(monkeypatch) frame, widget = _make_widget(db_session, mode="daily", city_label="Denver", render_style="modern") frame.theme = "terracotta" widgets.weather.render(db_session, frame, widget, 200, 160) assert ("terracotta", "weather") in calls