Files
espresso_frame/server/tests/test_widgets_weather.py
T
tfaour 5f4f8f2ea7
Build and push server image / test (push) Successful in 44s
Build and push server image / build-and-push (push) Successful in 3m33s
Build and push server image / deploy (push) Failing after 1m27s
Add a curated theme system for "modern" style widgets, inspired by Tesserae
Frame.theme (7 presets in app/theme_tokens.py) drives font family,
corner radius, drop shadow, and an accent hue for every modern-style
widget's header/accent region. Rich accent colors (not just the 6 flat
panel inks) are approximated via denser Bayer stippling confined to just
that region (html_render.ordered_dither_regions), so icon/text content
elsewhere stays exactly as crisp as it is today -- verified directly
against real Chromium renders, both in unit tests and via run-server.
"classic" is a byte-identical no-visual-change default: weather's header
keeps its original fixed blue gradient, tasks/calendar keep their flat
THEME_* ink.

Themes are purely stylistic -- battery's charge-level color, calendar/
tasks' per-owner event chips, and text's own per-widget font choice are
never touched.
2026-07-31 10:34:28 +00:00

221 lines
10 KiB
Python

"""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