Swap hand-drawn weather icons for Environment Canada's real icon set
Build and push server image / test (push) Successful in 29s
Build and push server image / build-and-push (push) Successful in 2m10s
Build and push server image / deploy (push) Successful in 51s

The hand-drawn glyphs (draw_cloud/draw_sun/draw_raindrop/draw_snowflake/
draw_lightning_bolt) are replaced by 7 vendored bitmaps, one per shared
weather category, sourced from weather.gc.ca's public icon set -- these
are small, flat-shaded images that dither cleanly onto the panel's
6-color palette and read as recognizable weather icons in a way the
hand-drawn attempt (a plain circle-with-ticks "sun") didn't. Used for
every provider's rendering (Open-Meteo, NWS, EC), not just when EC is
selected.

Vendored (not fetched live at render time), matching this project's
existing convention for the Noto Emoji fonts -- server/app/weather_icons/
SOURCE.md documents the source, attribution, and the licensing caveat
(this is a personal, non-commercial project; the icon images' own
copyright terms are less clearly permissive than the weather data's own
End-use Licence, since they're served from the public website rather
than ECCC's data servers).

draw_weather_icon's signature changes from (draw, cx, cy, r, category,
palette_rgb) to (img, cx, cy, r, category): pasting a bitmap needs the
Image object, not just an ImageDraw handle, and palette_rgb is no longer
needed since the shared _quantize step already maps whatever's on the
composited canvas to the frame's actual palette -- no per-icon color
resolution required anymore.
This commit is contained in:
2026-07-27 17:22:59 +00:00
parent 270979949f
commit efb0f2e22d
13 changed files with 130 additions and 174 deletions
+6 -2
View File
@@ -248,12 +248,16 @@ site, however far" matched a Miami, FL query to a site in Ontario,
1824 km away, once the box widened enough to cover the whole country). 1824 km away, once the box widened enough to cover the whole country).
`app/weather_render.py` holds every weather-related drawing primitive: `app/weather_render.py` holds every weather-related drawing primitive:
`draw_cloud`/`draw_weather_icon`/`draw_weather_row` (extracted out of `draw_weather_icon`/`draw_weather_row` (extracted out of
`calendar_render.py`, which still imports `draw_weather_row` for its own `calendar_render.py`, which still imports `draw_weather_row` for its own
embedded strip, unchanged) plus this widget's own `build_current`/ embedded strip, unchanged) plus this widget's own `build_current`/
`build_hourly`/`build_daily`/`build_multi_city`, dispatched by `build()` `build_hourly`/`build_daily`/`build_multi_city`, dispatched by `build()`
-- the weather analogue of `calendar_render.py`'s own `_build_tasks`/ -- the weather analogue of `calendar_render.py`'s own `_build_tasks`/
`render_tasks_preview_png` relationship. `render_tasks_preview_png` relationship. Icons are vendored bitmaps (`app/
weather_icons/`, one per shared category, sourced from Environment
Canada's own weather icon set -- see that directory's `SOURCE.md` for
attribution/licensing), not hand-drawn, and used for every provider's
rendering, not just when EC is selected as the provider.
## Known gaps (Phase 6, not yet done) ## Known gaps (Phase 6, not yet done)
+2 -4
View File
@@ -414,8 +414,7 @@ def _draw_agenda_day(img: Image.Image, draw: ImageDraw.ImageDraw, day: date, eve
weather_entries = _weather_for_day(weather_cities, day) weather_entries = _weather_for_day(weather_cities, day)
if weather_entries: if weather_entries:
y += draw_weather_row(img, draw, text_x0, y, text_w, weather_entries, y += draw_weather_row(img, draw, text_x0, y, text_w, weather_entries,
icon_r=title_font.size // 2, font=weather_font or body_font, units=weather_units, icon_r=title_font.size // 2, font=weather_font or body_font, units=weather_units)
palette_rgb=palette_rgb)
day_events = _events_on_day(events, day, tz) day_events = _events_on_day(events, day, tz)
row_h = body_font.size + 14 row_h = body_font.size + 14
@@ -637,8 +636,7 @@ def _build_week(events: list[dict], browse_offset: int, target_w: int, target_h:
weather_entries = _weather_for_day(weather_cities, day) weather_entries = _weather_for_day(weather_cities, day)
if weather_entries: if weather_entries:
y += draw_weather_row(img, draw, x0 + 4, y, col_w - 8, weather_entries, y += draw_weather_row(img, draw, x0 + 4, y, col_w - 8, weather_entries,
icon_r=8, font=weather_font, units=weather_units, show_labels=False, icon_r=8, font=weather_font, units=weather_units, show_labels=False)
palette_rgb=palette_rgb)
row_h = chip_font.size + 10 row_h = chip_font.size + 10
max_rows = max(0, (target_h - MARGIN - y) // row_h) max_rows = max(0, (target_h - MARGIN - y) // row_h)
day_events = _events_on_day(events, day, tz) day_events = _events_on_day(events, day, tz)
+2 -2
View File
@@ -35,8 +35,8 @@ HEADERS = {"User-Agent": "espresso_frame-weather-widget (self-hosted photo frame
# the same handful of categories Open-Meteo's _CODE_CATEGORIES maps WMO # the same handful of categories Open-Meteo's _CODE_CATEGORIES maps WMO
# codes to. "wind_"-prefixed variants (e.g. wind_skc) are just the same # codes to. "wind_"-prefixed variants (e.g. wind_skc) are just the same
# sky condition plus wind -- stripped before lookup, since this project's # sky condition plus wind -- stripped before lookup, since this project's
# hand-drawn icons (app/weather_render.py) don't have a separate windy # vendored icon set (app/weather_render.py, app/weather_icons/) doesn't
# glyph. # have a separate windy glyph.
_ICON_CODE_CATEGORIES = { _ICON_CODE_CATEGORIES = {
"skc": "clear", "clear": "clear", "skc": "clear", "clear": "clear",
"few": "partly_cloudy", "sct": "partly_cloudy", "few": "partly_cloudy", "sct": "partly_cloudy",
+34
View File
@@ -0,0 +1,34 @@
# Weather icons
Source: Environment and Climate Change Canada's public weather icon set,
served from `https://weather.gc.ca/weathericons/`.
© His Majesty the King in Right of Canada, as represented by the Minister
of Environment and Climate Change Canada. Vendored (not fetched live at
render time) under Canada.ca's non-commercial-reproduction terms
(https://www.canada.ca/en/transparency/terms.html) -- this is a personal,
non-commercial project. If this project's use ever becomes commercial,
these specific image files need their own clearance (the ECCC Data
Servers End-use Licence that covers the weather *data* app/weather/ec.py
consumes is more permissive, but that licence is scoped to ECCC's data
servers -- these icons are served from the public website, not that
domain, so it doesn't clearly cover them).
One icon per shared weather category (see app/weather/__init__.py's
module docstring for the category set), picked as a representative
"day" variant from EC's full icon set
(https://weather.gc.ca/mainmenu/icon_e.html has the legend):
| file | EC icon code | original URL |
|---------------------|:------------:|--------------------------------------------------|
| clear.gif | 00 | https://weather.gc.ca/weathericons/00.gif |
| partly_cloudy.gif | 02 | https://weather.gc.ca/weathericons/02.gif |
| cloudy.gif | 10 | https://weather.gc.ca/weathericons/10.gif |
| fog.gif | 24 | https://weather.gc.ca/weathericons/24.gif |
| rain.gif | 12 | https://weather.gc.ca/weathericons/12.gif |
| snow.gif | 17 | https://weather.gc.ca/weathericons/17.gif |
| thunderstorm.gif | 19 | https://weather.gc.ca/weathericons/19.gif |
Used for every weather provider's rendering (Open-Meteo, NWS, and EC
itself), not just when EC is the selected provider -- see
app/weather_render.py's draw_weather_icon.
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

+46 -124
View File
@@ -1,28 +1,36 @@
"""Weather icon-drawing primitives (draw_cloud/draw_weather_icon/ """Weather icon-drawing primitives (draw_weather_icon/draw_weather_row --
draw_weather_row -- extracted out of calendar_render.py, which still extracted out of calendar_render.py, which still imports draw_weather_row
imports draw_weather_row for its own embedded weather strip, unchanged) for its own embedded weather strip, unchanged) plus the standalone
plus the standalone weather widget's four per-mode renderers weather widget's four per-mode renderers (build_current/build_hourly/
(build_current/build_hourly/build_daily/build_multi_city, dispatched by build_daily/build_multi_city, dispatched by build()) and its preview-PNG
build()) and its preview-PNG wrapper -- the weather analogue of wrapper -- the weather analogue of calendar_render.py's own
calendar_render.py's own _build_tasks/render_tasks_preview_png _build_tasks/render_tasks_preview_png relationship.
relationship.
Every build_* function takes already-normalized data (see app/weather/'s Every build_* function takes already-normalized data (see app/weather/'s
provider modules -- a `category` key from the shared clear/partly_cloudy/ provider modules -- a `category` key from the shared clear/partly_cloudy/
cloudy/fog/rain/snow/thunderstorm set, never a raw provider code) and cloudy/fog/rain/snow/thunderstorm set, never a raw provider code) and
returns an RGB Image exactly target_w x target_h, same contract every returns an RGB Image exactly target_w x target_h, same contract every
other widget renderer in this project follows. other widget renderer in this project follows.
Icons are Environment Canada's own weather icon set (vendored at
app/weather_icons/, one per shared category -- see that directory's
SOURCE.md for attribution/licensing), not hand-drawn: these are small,
flat-shaded bitmaps that dither cleanly onto the panel's 6-color
palette, and read as recognizable weather icons at a glance in a way an
earlier hand-drawn attempt (a plain circle-with-ticks "sun") didn't.
Used for every provider's rendering, not just when EC is selected.
""" """
from __future__ import annotations from __future__ import annotations
import io import io
import math
from datetime import date, datetime from datetime import date, datetime
from functools import lru_cache
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont from PIL import Image, ImageDraw, ImageFont
from .image_pipeline import DEFAULT_PALETTE_RGB, _apply_manage_overlay, _quantize, draw_text, logical_render_size from .image_pipeline import _apply_manage_overlay, _quantize, draw_text, logical_render_size
MARGIN = 20 MARGIN = 20
BG = (255, 255, 255) BG = (255, 255, 255)
@@ -30,122 +38,37 @@ FG = (0, 0, 0)
MUTED = (110, 110, 110) MUTED = (110, 110, 110)
RULE = (0, 0, 0) RULE = (0, 0, 0)
_ICON_DIR = Path(__file__).parent / "weather_icons"
def _ink(palette_rgb: list | None, index: int) -> tuple[int, int, int]: _ICON_FALLBACK = "cloudy" # unrecognized category (shouldn't happen with a valid provider) -- see draw_weather_icon
"""One of this frame's actual panel colors by DEFAULT_PALETTE_RGB
index (2=yellow, 3=red, 4=blue, 5=green -- 0/1 are black/white,
already this module's BG/FG) -- same resolution idiom as
calendar_render.py's _event_colors, so a custom palette override
(Frame.palette_rgb) still gets its own actual yellow/blue, not a
hardcoded RGB triple."""
return tuple((palette_rgb or DEFAULT_PALETTE_RGB)[index])
def draw_cloud(draw: ImageDraw.ImageDraw, cx: float, cy: float, r: float, fill=BG, outline=FG) -> None: @lru_cache(maxsize=None)
"""A simple puffy-cloud silhouette (three overlapping lobes + a base) def _icon_asset(category: str) -> Image.Image:
with a clean outline -- drawn as one outline-color pass slightly """This category's vendored icon, loaded once (a handful of small
larger than the shapes, then the same shapes again in `fill` on top. static files, never fetched over the network at render time) and
Overlapping ellipses each drawn with their own `outline=` would leave cached at its native resolution -- draw_weather_icon resizes a copy
visible seams where they cross; this double-draw trick sidesteps that per call, since the requested size varies with icon_r."""
entirely regardless of how the lobes overlap.""" path = _ICON_DIR / f"{category}.gif"
stroke = 2 if not path.exists():
lobes = [ path = _ICON_DIR / f"{_ICON_FALLBACK}.gif" # unrecognized category -- shouldn't happen with a valid provider
(cx - r, cy - r * 0.3, cx - r * 0.1, cy + r * 0.6), return Image.open(path).convert("RGBA")
(cx - r * 0.45, cy - r * 0.8, cx + r * 0.35, cy + r * 0.25),
(cx, cy - r * 0.35, cx + r, cy + r * 0.6),
]
base = (cx - r * 0.8, cy, cx + r * 0.8, cy + r * 0.5)
for x0, y0, x1, y1 in lobes:
draw.ellipse([x0 - stroke, y0 - stroke, x1 + stroke, y1 + stroke], fill=outline)
draw.rectangle([base[0] - stroke, base[1], base[2] + stroke, base[3] + stroke], fill=outline)
for x0, y0, x1, y1 in lobes:
draw.ellipse([x0, y0, x1, y1], fill=fill)
draw.rectangle(base, fill=fill)
def draw_sun(draw: ImageDraw.ImageDraw, cx: float, cy: float, r: float, color) -> None: def draw_weather_icon(img: Image.Image, cx: float, cy: float, r: float, category: str) -> None:
"""A filled disc + 8 thick radiating rays -- the standard weather-app """Pastes this category's icon (see module docstring), scaled to
"sun" glyph, in the panel's actual yellow ink rather than a bare roughly 2r wide, centered on (cx, cy). Alpha-composited via the
outline (a plain circle-with-4-ticks at small sizes read as a icon's own transparency (confirmed present in the source GIFs), so
crosshair/target, not a sun).""" only the glyph itself shows against whatever's already on `img`."""
draw.ellipse([cx - r * 0.55, cy - r * 0.55, cx + r * 0.55, cy + r * 0.55], fill=color) icon = _icon_asset(category)
for i in range(8): size = max(1, round(r * 2))
angle = i * (math.pi / 4) aspect_h = max(1, round(size * icon.height / icon.width))
dx, dy = math.cos(angle), math.sin(angle) resized = icon.resize((size, aspect_h), Image.LANCZOS)
draw.line([(cx + dx * r * 0.68, cy + dy * r * 0.68), (cx + dx * r * 1.05, cy + dy * r * 1.05)], img.paste(resized, (round(cx - size / 2), round(cy - aspect_h / 2)), resized)
fill=color, width=max(2, round(r * 0.14)))
def draw_raindrop(draw: ImageDraw.ImageDraw, x: float, y: float, size: float, color) -> None:
"""A rounded teardrop (point up, bulb down) -- the standard rain
glyph, not a bare diagonal tick."""
draw.polygon([(x, y), (x - size * 0.38, y + size * 0.55), (x + size * 0.38, y + size * 0.55)], fill=color)
draw.ellipse([x - size * 0.4, y + size * 0.25, x + size * 0.4, y + size * 1.05], fill=color)
def draw_snowflake(draw: ImageDraw.ImageDraw, x: float, y: float, r: float, color) -> None:
"""A 6-pointed asterisk -- the standard snowflake glyph."""
for i in range(3):
angle = i * (math.pi / 3)
dx, dy = math.cos(angle) * r, math.sin(angle) * r
draw.line([(x - dx, y - dy), (x + dx, y + dy)], fill=color, width=max(2, round(r * 0.3)))
def draw_lightning_bolt(draw: ImageDraw.ImageDraw, cx: float, cy: float, size: float, color) -> None:
"""A zigzag bolt polygon -- the standard lightning glyph, not a bare
3-segment line."""
points = [
(cx + size * 0.15, cy - size * 0.7),
(cx - size * 0.35, cy + size * 0.05),
(cx - size * 0.05, cy + size * 0.05),
(cx - size * 0.2, cy + size * 0.7),
(cx + size * 0.4, cy - size * 0.1),
(cx + size * 0.05, cy - size * 0.1),
]
draw.polygon(points, fill=color)
def draw_weather_icon(draw: ImageDraw.ImageDraw, cx: float, cy: float, r: float, category: str,
palette_rgb: list | None = None) -> None:
"""A small glyph for one weather category -- no custom font/icon
asset (hand-primitives only, same approach calendar_render.py uses
elsewhere for e.g. month view's density dots), but drawn in the
panel's own ink colors (yellow sun/bolt, blue rain/snow) rather than
flat black -- a plain monochrome outline at small sizes read as
abstract shapes (a "sun" that looked like a crosshair), not
recognizable weather icons."""
yellow = _ink(palette_rgb, 2)
blue = _ink(palette_rgb, 4)
if category == "clear":
draw_sun(draw, cx, cy, r, yellow)
return
if category == "partly_cloudy":
draw_sun(draw, cx - r * 0.45, cy - r * 0.45, r * 0.75, yellow)
draw_cloud(draw, cx + r * 0.1, cy + r * 0.2, r * 0.9)
return
cloud_cy = cy if category in ("cloudy", "fog") else cy - r * 0.25
draw_cloud(draw, cx, cloud_cy, r)
if category == "fog":
for i in range(3):
y = cy + r * 0.55 + i * (r * 0.4)
draw.line([(cx - r, y), (cx + r, y)], fill=FG, width=2)
elif category == "rain":
for dx in (-0.55, 0, 0.55):
draw_raindrop(draw, cx + dx * r, cloud_cy + r * 0.55, r * 0.55, blue)
elif category == "snow":
for dx in (-0.55, 0, 0.55):
draw_snowflake(draw, cx + dx * r, cloud_cy + r * 0.85, r * 0.3, blue)
elif category == "thunderstorm":
draw_lightning_bolt(draw, cx, cloud_cy + r * 0.7, r * 0.7, yellow)
def draw_weather_row(img: Image.Image, draw: ImageDraw.ImageDraw, x0: int, y0: int, max_w: int, def draw_weather_row(img: Image.Image, draw: ImageDraw.ImageDraw, x0: int, y0: int, max_w: int,
entries: list[dict], icon_r: int, font: ImageFont.ImageFont, units: str, entries: list[dict], icon_r: int, font: ImageFont.ImageFont, units: str,
show_labels: bool = True, palette_rgb: list | None = None) -> int: show_labels: bool = True) -> int:
"""Draws one or more cities' weather side by side starting at """Draws one or more cities' weather side by side starting at
(x0, y0), stopping once another entry wouldn't fit within max_w (x0, y0), stopping once another entry wouldn't fit within max_w
(narrow views like week columns just end up showing fewer cities -- (narrow views like week columns just end up showing fewer cities --
@@ -165,7 +88,7 @@ def draw_weather_row(img: Image.Image, draw: ImageDraw.ImageDraw, x0: int, y0: i
if drew_any and x + entry_w > x0 + max_w: if drew_any and x + entry_w > x0 + max_w:
break break
cx, cy = x + icon_r, y0 + icon_r cx, cy = x + icon_r, y0 + icon_r
draw_weather_icon(draw, cx, cy, icon_r, entry["category"], palette_rgb) draw_weather_icon(img, cx, cy, icon_r, entry["category"])
draw_text(img, (x + icon_r * 2 + 6, y0 + (row_h - font.size) // 2), label, font) draw_text(img, (x + icon_r * 2 + 6, y0 + (row_h - font.size) // 2), label, font)
x += entry_w x += entry_w
drew_any = True drew_any = True
@@ -217,7 +140,7 @@ def build_current(entry: dict | None, target_w: int, target_h: int, palette_rgb:
icon_r = max(20, min(target_w, target_h) // 4) icon_r = max(20, min(target_w, target_h) // 4)
cx, cy = target_w // 2, target_h // 2 - icon_r // 2 cx, cy = target_w // 2, target_h // 2 - icon_r // 2
draw_weather_icon(draw, cx, cy, icon_r, entry["category"], palette_rgb) draw_weather_icon(img, cx, cy, icon_r, entry["category"])
unit_suffix = "F" if units == "fahrenheit" else "C" unit_suffix = "F" if units == "fahrenheit" else "C"
temp_size = max(24, min(target_w, target_h) // 3) temp_size = max(24, min(target_w, target_h) // 3)
@@ -281,7 +204,7 @@ def build_hourly(entries: list[dict], target_w: int, target_h: int, palette_rgb:
tbbox = draw.textbbox((0, 0), time_label, font=label_font) tbbox = draw.textbbox((0, 0), time_label, font=label_font)
draw_text(img, (cx - (tbbox[2] - tbbox[0]) // 2, y), time_label, label_font, MUTED) draw_text(img, (cx - (tbbox[2] - tbbox[0]) // 2, y), time_label, label_font, MUTED)
cy = y + label_size + 10 + icon_r cy = y + label_size + 10 + icon_r
draw_weather_icon(draw, cx, cy, icon_r, entry["category"], palette_rgb) draw_weather_icon(img, cx, cy, icon_r, entry["category"])
temp_label = temp_labels[i] temp_label = temp_labels[i]
tempbbox = draw.textbbox((0, 0), temp_label, font=label_font) tempbbox = draw.textbbox((0, 0), temp_label, font=label_font)
draw_text(img, (cx - (tempbbox[2] - tempbbox[0]) // 2, cy + icon_r + 6), temp_label, label_font) draw_text(img, (cx - (tempbbox[2] - tempbbox[0]) // 2, cy + icon_r + 6), temp_label, label_font)
@@ -326,7 +249,7 @@ def build_daily(daily: dict[str, dict], target_w: int, target_h: int, palette_rg
lbbox = draw.textbbox((0, 0), label, font=label_font) lbbox = draw.textbbox((0, 0), label, font=label_font)
draw_text(img, (x0 + col_w // 2 - (lbbox[2] - lbbox[0]) // 2, y), label, label_font, MUTED) draw_text(img, (x0 + col_w // 2 - (lbbox[2] - lbbox[0]) // 2, y), label, label_font, MUTED)
cx, cy = x0 + col_w // 2, y + label_size + 10 + icon_r cx, cy = x0 + col_w // 2, y + label_size + 10 + icon_r
draw_weather_icon(draw, cx, cy, icon_r, d["category"], palette_rgb) draw_weather_icon(img, cx, cy, icon_r, d["category"])
tbbox = draw.textbbox((0, 0), temps, font=label_font) tbbox = draw.textbbox((0, 0), temps, font=label_font)
draw_text(img, (cx - (tbbox[2] - tbbox[0]) // 2, cy + icon_r + 8), temps, label_font) draw_text(img, (cx - (tbbox[2] - tbbox[0]) // 2, cy + icon_r + 8), temps, label_font)
return img return img
@@ -364,8 +287,7 @@ def build_multi_city(cities: list[dict], target_w: int, target_h: int, palette_r
font_size = _fit_font_size(draw, labels, col_w - (icon_r * 2 + 24), max_size=icon_r) font_size = _fit_font_size(draw, labels, col_w - (icon_r * 2 + 24), max_size=icon_r)
font = ImageFont.load_default(size=font_size) font = ImageFont.load_default(size=font_size)
y = max(MARGIN, (target_h - (icon_r * 2 + 8)) // 2) y = max(MARGIN, (target_h - (icon_r * 2 + 8)) // 2)
draw_weather_row(img, draw, MARGIN, y, text_w, cities, icon_r=icon_r, font=font, units=units, draw_weather_row(img, draw, MARGIN, y, text_w, cities, icon_r=icon_r, font=font, units=units, show_labels=True)
show_labels=True, palette_rgb=palette_rgb)
return img return img
+40 -42
View File
@@ -3,16 +3,15 @@ Mirrors calendar_render.py's own testing posture (this module has no
dedicated test file there either, since it's exercised indirectly via dedicated test file there either, since it's exercised indirectly via
test_widgets_calendar.py) but covers the non-obvious behavior worth test_widgets_calendar.py) but covers the non-obvious behavior worth
pinning down directly: build_multi_city's label shortening, and that pinning down directly: build_multi_city's label shortening, and that
weather icons actually use the panel's ink colors (not flat black -- every shared category actually has a distinct vendored icon (a missing
caught via browser verification that a monochrome sun read as an or misnamed asset file would otherwise only surface as a silent
unrecognizable crosshair glyph, not a sun).""" fallback to the "cloudy" glyph, not an error)."""
from __future__ import annotations from __future__ import annotations
from PIL import Image, ImageDraw from PIL import Image
from app import weather_render from app import weather_render
from app.image_pipeline import DEFAULT_PALETTE_RGB
def test_build_multi_city_shortens_full_geocoder_labels_for_display(monkeypatch): def test_build_multi_city_shortens_full_geocoder_labels_for_display(monkeypatch):
@@ -26,10 +25,9 @@ def test_build_multi_city_shortens_full_geocoder_labels_for_display(monkeypatch)
seen_labels = [] seen_labels = []
real_draw_weather_row = weather_render.draw_weather_row real_draw_weather_row = weather_render.draw_weather_row
def spy(img, draw, x0, y0, max_w, entries, icon_r, font, units, show_labels=True, palette_rgb=None): def spy(img, draw, x0, y0, max_w, entries, icon_r, font, units, show_labels=True):
seen_labels.extend(e["label"] for e in entries) seen_labels.extend(e["label"] for e in entries)
return real_draw_weather_row(img, draw, x0, y0, max_w, entries, icon_r, font, units, show_labels, return real_draw_weather_row(img, draw, x0, y0, max_w, entries, icon_r, font, units, show_labels)
palette_rgb)
monkeypatch.setattr(weather_render, "draw_weather_row", spy) monkeypatch.setattr(weather_render, "draw_weather_row", spy)
@@ -47,40 +45,40 @@ def test_build_multi_city_empty_list_returns_blank_canvas():
assert img.size == (400, 150) assert img.size == (400, 150)
def _colors_present(img: Image.Image) -> set[tuple[int, int, int]]: def test_icon_asset_exists_and_loads_for_every_shared_category():
return {c for _, c in img.getcolors(maxcolors=100_000)} """The shared category set (see app/weather/__init__.py's module
docstring) must each resolve to a real vendored file, not silently
fall back to "cloudy" -- a typo'd filename would otherwise only show
up as every OTHER category quietly looking like a plain cloud."""
for category in ("clear", "partly_cloudy", "cloudy", "fog", "rain", "snow", "thunderstorm"):
path = weather_render._ICON_DIR / f"{category}.gif"
assert path.exists(), f"missing vendored icon for {category!r}"
icon = weather_render._icon_asset(category)
assert icon.mode == "RGBA"
assert icon.width > 0 and icon.height > 0
def test_draw_weather_icon_clear_uses_panel_yellow_not_flat_black(): def test_icon_asset_is_cached():
assert weather_render._icon_asset("clear") is weather_render._icon_asset("clear")
def test_draw_weather_icon_pastes_a_visibly_different_glyph_per_category():
"""Not a pixel-exact check (the actual art is a vendored asset, not
something this test should hardcode) -- just confirms distinct
categories actually produce visibly different canvases, i.e. the
right file is being loaded per category rather than one glyph
silently reused for all of them."""
rendered = {}
for category in ("clear", "rain", "snow", "thunderstorm"):
img = Image.new("RGB", (100, 100), (255, 255, 255))
weather_render.draw_weather_icon(img, 50, 50, 30, category)
rendered[category] = img.tobytes()
assert len(set(rendered.values())) == len(rendered)
def test_draw_weather_icon_unrecognized_category_falls_back_to_cloudy():
img = Image.new("RGB", (100, 100), (255, 255, 255)) img = Image.new("RGB", (100, 100), (255, 255, 255))
draw = ImageDraw.Draw(img) weather_render.draw_weather_icon(img, 50, 50, 30, "not_a_real_category")
weather_render.draw_weather_icon(draw, 50, 50, 30, "clear") fallback = Image.new("RGB", (100, 100), (255, 255, 255))
colors = _colors_present(img) weather_render.draw_weather_icon(fallback, 50, 50, 30, "cloudy")
assert tuple(DEFAULT_PALETTE_RGB[2]) in colors # yellow assert img.tobytes() == fallback.tobytes()
assert (0, 0, 0) not in colors # no flat-black sun
def test_draw_weather_icon_rain_uses_panel_blue():
img = Image.new("RGB", (100, 100), (255, 255, 255))
draw = ImageDraw.Draw(img)
weather_render.draw_weather_icon(draw, 50, 50, 30, "rain")
assert tuple(DEFAULT_PALETTE_RGB[4]) in _colors_present(img) # blue raindrops
def test_draw_weather_icon_thunderstorm_uses_panel_yellow():
img = Image.new("RGB", (100, 100), (255, 255, 255))
draw = ImageDraw.Draw(img)
weather_render.draw_weather_icon(draw, 50, 50, 30, "thunderstorm")
assert tuple(DEFAULT_PALETTE_RGB[2]) in _colors_present(img) # yellow bolt
def test_draw_weather_icon_respects_a_custom_frame_palette():
"""A frame with an Advanced-configuration palette override (see
Frame.palette_rgb) should still get ITS actual yellow, not the
hardcoded default -- same resolution rule as calendar_render.py's
_event_colors."""
custom_palette = [(0, 0, 0), (255, 255, 255), (10, 20, 30), (0, 0, 0), (0, 0, 0), (0, 0, 0)]
img = Image.new("RGB", (100, 100), (255, 255, 255))
draw = ImageDraw.Draw(img)
weather_render.draw_weather_icon(draw, 50, 50, 30, "clear", palette_rgb=custom_palette)
assert (10, 20, 30) in _colors_present(img)