diff --git a/docs/widgets.md b/docs/widgets.md index d7222f8..fd6f6cd 100644 --- a/docs/widgets.md +++ b/docs/widgets.md @@ -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). `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 embedded strip, unchanged) plus this widget's own `build_current`/ `build_hourly`/`build_daily`/`build_multi_city`, dispatched by `build()` -- 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) diff --git a/server/app/calendar_render.py b/server/app/calendar_render.py index dccb7f7..b7479f2 100644 --- a/server/app/calendar_render.py +++ b/server/app/calendar_render.py @@ -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) if 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, - palette_rgb=palette_rgb) + icon_r=title_font.size // 2, font=weather_font or body_font, units=weather_units) day_events = _events_on_day(events, day, tz) 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) if 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, - palette_rgb=palette_rgb) + icon_r=8, font=weather_font, units=weather_units, show_labels=False) row_h = chip_font.size + 10 max_rows = max(0, (target_h - MARGIN - y) // row_h) day_events = _events_on_day(events, day, tz) diff --git a/server/app/weather/nws.py b/server/app/weather/nws.py index 36a3433..f13ea82 100644 --- a/server/app/weather/nws.py +++ b/server/app/weather/nws.py @@ -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 # codes to. "wind_"-prefixed variants (e.g. wind_skc) are just the same # sky condition plus wind -- stripped before lookup, since this project's -# hand-drawn icons (app/weather_render.py) don't have a separate windy -# glyph. +# vendored icon set (app/weather_render.py, app/weather_icons/) doesn't +# have a separate windy glyph. _ICON_CODE_CATEGORIES = { "skc": "clear", "clear": "clear", "few": "partly_cloudy", "sct": "partly_cloudy", diff --git a/server/app/weather_icons/SOURCE.md b/server/app/weather_icons/SOURCE.md new file mode 100644 index 0000000..d4eaa16 --- /dev/null +++ b/server/app/weather_icons/SOURCE.md @@ -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. diff --git a/server/app/weather_icons/clear.gif b/server/app/weather_icons/clear.gif new file mode 100644 index 0000000..15f51e5 Binary files /dev/null and b/server/app/weather_icons/clear.gif differ diff --git a/server/app/weather_icons/cloudy.gif b/server/app/weather_icons/cloudy.gif new file mode 100644 index 0000000..464868b Binary files /dev/null and b/server/app/weather_icons/cloudy.gif differ diff --git a/server/app/weather_icons/fog.gif b/server/app/weather_icons/fog.gif new file mode 100644 index 0000000..9df3002 Binary files /dev/null and b/server/app/weather_icons/fog.gif differ diff --git a/server/app/weather_icons/partly_cloudy.gif b/server/app/weather_icons/partly_cloudy.gif new file mode 100644 index 0000000..8de16ce Binary files /dev/null and b/server/app/weather_icons/partly_cloudy.gif differ diff --git a/server/app/weather_icons/rain.gif b/server/app/weather_icons/rain.gif new file mode 100644 index 0000000..818a10c Binary files /dev/null and b/server/app/weather_icons/rain.gif differ diff --git a/server/app/weather_icons/snow.gif b/server/app/weather_icons/snow.gif new file mode 100644 index 0000000..3313f75 Binary files /dev/null and b/server/app/weather_icons/snow.gif differ diff --git a/server/app/weather_icons/thunderstorm.gif b/server/app/weather_icons/thunderstorm.gif new file mode 100644 index 0000000..6644ace Binary files /dev/null and b/server/app/weather_icons/thunderstorm.gif differ diff --git a/server/app/weather_render.py b/server/app/weather_render.py index f163cd6..b903dab 100644 --- a/server/app/weather_render.py +++ b/server/app/weather_render.py @@ -1,28 +1,36 @@ -"""Weather icon-drawing primitives (draw_cloud/draw_weather_icon/ -draw_weather_row -- extracted out of calendar_render.py, which still -imports draw_weather_row for its own embedded weather strip, unchanged) -plus the standalone weather widget's four per-mode renderers -(build_current/build_hourly/build_daily/build_multi_city, dispatched by -build()) and its preview-PNG wrapper -- the weather analogue of -calendar_render.py's own _build_tasks/render_tasks_preview_png -relationship. +"""Weather icon-drawing primitives (draw_weather_icon/draw_weather_row -- +extracted out of calendar_render.py, which still imports draw_weather_row +for its own embedded weather strip, unchanged) plus the standalone +weather widget's four per-mode renderers (build_current/build_hourly/ +build_daily/build_multi_city, dispatched by build()) and its preview-PNG +wrapper -- the weather analogue of calendar_render.py's own +_build_tasks/render_tasks_preview_png relationship. Every build_* function takes already-normalized data (see app/weather/'s provider modules -- a `category` key from the shared clear/partly_cloudy/ 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 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 import io -import math from datetime import date, datetime +from functools import lru_cache +from pathlib import Path 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 BG = (255, 255, 255) @@ -30,122 +38,37 @@ FG = (0, 0, 0) MUTED = (110, 110, 110) RULE = (0, 0, 0) - -def _ink(palette_rgb: list | None, index: int) -> tuple[int, int, int]: - """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]) +_ICON_DIR = Path(__file__).parent / "weather_icons" +_ICON_FALLBACK = "cloudy" # unrecognized category (shouldn't happen with a valid provider) -- see draw_weather_icon -def draw_cloud(draw: ImageDraw.ImageDraw, cx: float, cy: float, r: float, fill=BG, outline=FG) -> None: - """A simple puffy-cloud silhouette (three overlapping lobes + a base) - with a clean outline -- drawn as one outline-color pass slightly - larger than the shapes, then the same shapes again in `fill` on top. - Overlapping ellipses each drawn with their own `outline=` would leave - visible seams where they cross; this double-draw trick sidesteps that - entirely regardless of how the lobes overlap.""" - stroke = 2 - lobes = [ - (cx - r, cy - r * 0.3, cx - r * 0.1, cy + r * 0.6), - (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) +@lru_cache(maxsize=None) +def _icon_asset(category: str) -> Image.Image: + """This category's vendored icon, loaded once (a handful of small + static files, never fetched over the network at render time) and + cached at its native resolution -- draw_weather_icon resizes a copy + per call, since the requested size varies with icon_r.""" + path = _ICON_DIR / f"{category}.gif" + if not path.exists(): + path = _ICON_DIR / f"{_ICON_FALLBACK}.gif" # unrecognized category -- shouldn't happen with a valid provider + return Image.open(path).convert("RGBA") -def draw_sun(draw: ImageDraw.ImageDraw, cx: float, cy: float, r: float, color) -> None: - """A filled disc + 8 thick radiating rays -- the standard weather-app - "sun" glyph, in the panel's actual yellow ink rather than a bare - outline (a plain circle-with-4-ticks at small sizes read as a - crosshair/target, not a sun).""" - draw.ellipse([cx - r * 0.55, cy - r * 0.55, cx + r * 0.55, cy + r * 0.55], fill=color) - for i in range(8): - angle = i * (math.pi / 4) - dx, dy = math.cos(angle), math.sin(angle) - draw.line([(cx + dx * r * 0.68, cy + dy * r * 0.68), (cx + dx * r * 1.05, cy + dy * r * 1.05)], - 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_icon(img: Image.Image, cx: float, cy: float, r: float, category: str) -> None: + """Pastes this category's icon (see module docstring), scaled to + roughly 2r wide, centered on (cx, cy). Alpha-composited via the + icon's own transparency (confirmed present in the source GIFs), so + only the glyph itself shows against whatever's already on `img`.""" + icon = _icon_asset(category) + size = max(1, round(r * 2)) + aspect_h = max(1, round(size * icon.height / icon.width)) + resized = icon.resize((size, aspect_h), Image.LANCZOS) + img.paste(resized, (round(cx - size / 2), round(cy - aspect_h / 2)), resized) 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, - 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 (x0, y0), stopping once another entry wouldn't fit within max_w (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: break 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) x += entry_w 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) 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" 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) draw_text(img, (cx - (tbbox[2] - tbbox[0]) // 2, y), time_label, label_font, MUTED) 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] 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) @@ -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) 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 - 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) draw_text(img, (cx - (tbbox[2] - tbbox[0]) // 2, cy + icon_r + 8), temps, label_font) 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 = ImageFont.load_default(size=font_size) 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, - show_labels=True, palette_rgb=palette_rgb) + draw_weather_row(img, draw, MARGIN, y, text_w, cities, icon_r=icon_r, font=font, units=units, show_labels=True) return img diff --git a/server/tests/test_weather_render.py b/server/tests/test_weather_render.py index bb77476..c088243 100644 --- a/server/tests/test_weather_render.py +++ b/server/tests/test_weather_render.py @@ -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 test_widgets_calendar.py) but covers the non-obvious behavior worth pinning down directly: build_multi_city's label shortening, and that -weather icons actually use the panel's ink colors (not flat black -- -caught via browser verification that a monochrome sun read as an -unrecognizable crosshair glyph, not a sun).""" +every shared category actually has a distinct vendored icon (a missing +or misnamed asset file would otherwise only surface as a silent +fallback to the "cloudy" glyph, not an error).""" from __future__ import annotations -from PIL import Image, ImageDraw +from PIL import Image 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): @@ -26,10 +25,9 @@ def test_build_multi_city_shortens_full_geocoder_labels_for_display(monkeypatch) seen_labels = [] 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) - return real_draw_weather_row(img, draw, x0, y0, max_w, entries, icon_r, font, units, show_labels, - palette_rgb) + return real_draw_weather_row(img, draw, x0, y0, max_w, entries, icon_r, font, units, show_labels) 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) -def _colors_present(img: Image.Image) -> set[tuple[int, int, int]]: - return {c for _, c in img.getcolors(maxcolors=100_000)} +def test_icon_asset_exists_and_loads_for_every_shared_category(): + """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)) - draw = ImageDraw.Draw(img) - weather_render.draw_weather_icon(draw, 50, 50, 30, "clear") - colors = _colors_present(img) - assert tuple(DEFAULT_PALETTE_RGB[2]) in colors # yellow - 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) + weather_render.draw_weather_icon(img, 50, 50, 30, "not_a_real_category") + fallback = Image.new("RGB", (100, 100), (255, 255, 255)) + weather_render.draw_weather_icon(fallback, 50, 50, 30, "cloudy") + assert img.tobytes() == fallback.tobytes()