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.
112 lines
5.0 KiB
Python
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"}
|