"""app.weather_render -- pure-logic drawing helpers, no HTTP/DB. 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 are filled with the panel's *exact* ink colors (not an arbitrary bitmap's anti-aliased ones) -- the whole reason icons are hand-drawn rather than a fetched/vendored image: an exact palette match quantizes with zero dithering error to diffuse, where anything else dithers into a visible speckle at these small on-panel sizes.""" from __future__ import annotations from PIL import Image, ImageDraw from app import weather_render from app.image_pipeline import DEFAULT_PALETTE_RGB, _quantize def test_build_multi_city_shortens_full_geocoder_labels_for_display(monkeypatch): """Cached entries carry the full disambiguated geocoder label (e.g. "Seattle, Washington, United States" -- see get_or_refresh_weather_ widget_data/weather.geocode_city). Drawing the whole thing made a single city's row overflow past the widget's own width in practice (caught via browser verification) -- only the city name should reach draw_weather_row, same shortening calendar_render.py's _weather_for_day already does for its own embedded strip.""" 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): 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) monkeypatch.setattr(weather_render, "draw_weather_row", spy) cities = [ {"label": "Seattle, Washington, United States", "high": 65, "low": 50, "category": "rain"}, {"label": "Portland, Oregon, United States", "high": 75, "low": 55, "category": "clear"}, ] img = weather_render.build_multi_city(cities, 400, 150) assert img.size == (400, 150) assert seen_labels == ["Seattle", "Portland"] def test_build_multi_city_empty_list_returns_blank_canvas(): img = weather_render.build_multi_city([], 400, 150) 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_draw_weather_icon_clear_uses_exact_panel_yellow(): img = Image.new("RGB", (100, 100), (255, 255, 255)) draw = ImageDraw.Draw(img) weather_render.draw_weather_icon(draw, 50, 50, 30, "clear") assert tuple(DEFAULT_PALETTE_RGB[2]) in _colors_present(img) # yellow sun assert (0, 0, 0) not in _colors_present(img) # not a flat-black glyph def test_draw_weather_icon_rain_uses_exact_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_exact_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) def test_icon_fill_quantizes_uniformly_with_no_dithering_speckle(): """The actual point of hand-drawing icons in exact panel colors: a small crop taken from deep inside the sun disc (nowhere near its own edge) is a single flat fill -- if that fill weren't an exact palette match, _quantize's Floyd-Steinberg dithering would diffuse rounding error across it, breaking the crop into a speckle of 2+ colors. An exact match has zero error to diffuse, so the crop stays perfectly uniform after quantizing, same as before.""" img = Image.new("RGB", (200, 200), (255, 255, 255)) draw = ImageDraw.Draw(img) weather_render.draw_weather_icon(draw, 100, 100, 60, "clear") quantized = _quantize(img, DEFAULT_PALETTE_RGB, dither_strength=1.0).convert("RGB") # A 20x20 crop centered on the disc -- draw_sun's disc radius is # 0.55*60=33px, comfortably clear of both the outer edge and the ray # triangles' base line. crop = quantized.crop((90, 90, 110, 110)) colors_in_crop = _colors_present(crop) assert colors_in_crop == {tuple(DEFAULT_PALETTE_RGB[2])} # solid yellow, no speckle