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.
221 lines
10 KiB
Python
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
|