Files
espresso_frame/server/tests/test_widgets_weather.py
T
tfaour 52ebafab78
Build and push server image / test (push) Successful in 1m11s
Build and push server image / build-and-push (push) Successful in 2m3s
Build and push server image / deploy (push) Successful in 52s
Add standalone weather widget (current/hourly/daily/multi-city, pluggable providers)
New widget type with four display modes -- current conditions, an
hourly forecast strip, a multi-day forecast, and several cities' current
day side by side -- backed by a pluggable provider registry (app/weather/,
mirroring the app/widgets/ dispatch pattern): Open-Meteo (worldwide) and
NWS (US-only) both wired up now, Environment Canada documented as the
next one to add given its more involved station/grid-lookup API.

The calendar widget's existing embedded weather strip is untouched and
still Open-Meteo-only; this lifts the same underlying icon-drawing
primitives (now shared via app/weather_render.py, calendar_render.py
still imports draw_weather_row unchanged) into a widget that can be
placed and sized on its own. Icons are redrawn in the panel's actual ink
colors (yellow sun/bolt, blue rain/snow) instead of flat black, and
build_multi_city's icon/font sizing now scales with how many cities need
to fit rather than the box's height alone -- both fixed after catching
them via live browser verification, along with a mode-switch cache-shape
crash and a mobile-width dialog overflow.

New WeatherWidgetConfig table (migration 24), grid footprint, widget
module, common.py fetch/cache helper, router endpoints (location set/
clear, city add/remove, preview), dialog template + JS, and full test
coverage (providers, widget render, HTTP endpoints, migration replay).
docs/widgets.md and CLAUDE.md's TODO updated accordingly.
2026-07-27 16:16:43 +00:00

112 lines
5.0 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 app import grid, 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"}