diff --git a/src/odl_renderer/elements/visualizations.py b/src/odl_renderer/elements/visualizations.py index a586176..6355e6c 100644 --- a/src/odl_renderer/elements/visualizations.py +++ b/src/odl_renderer/elements/visualizations.py @@ -191,6 +191,10 @@ def _process_entity_segments( try: value = float(state["state"]) * value_scale timestamp = datetime.fromisoformat(state["last_changed"]) + # Normalize naive timestamps to UTC so they can be subtracted from the + # timezone-aware plot range without raising TypeError. + if timestamp.tzinfo is None: + timestamp = timestamp.replace(tzinfo=timezone.utc) should_break = False if isinstance(span_gaps, (int, float)) and span_gaps is not True and span_gaps is not False: diff --git a/src/odl_renderer/media_loader.py b/src/odl_renderer/media_loader.py index 2cee83a..3307edd 100644 --- a/src/odl_renderer/media_loader.py +++ b/src/odl_renderer/media_loader.py @@ -12,6 +12,10 @@ _LOGGER = logging.getLogger(__name__) +# Warn only once about creating a throwaway HTTP session, so a dashboard that +# re-renders every update doesn't repeat the message on every frame. +_warned_sessionless = False + async def load_image( source: str | bytes | Image.Image, @@ -97,6 +101,15 @@ async def _load_from_http( response.raise_for_status() image_bytes = await response.read() else: + global _warned_sessionless + if not _warned_sessionless: + _warned_sessionless = True + _LOGGER.warning( + "load_image called without a session; creating a temporary aiohttp " + "ClientSession per request. Pass a shared session for connection " + "pooling and DNS caching." + ) + import aiohttp async with aiohttp.ClientSession() as temp_session: @@ -104,7 +117,9 @@ async def _load_from_http( response.raise_for_status() image_bytes = await response.read() - return Image.open(io.BytesIO(image_bytes)) + img = Image.open(io.BytesIO(image_bytes)) + img.load() # decode now, not later mid-composite + return img except Exception as err: raise ValueError(f"Failed to load image from {url}: {err}") from err @@ -123,7 +138,9 @@ def _load_from_file(file_path: str) -> Image.Image: ValueError: If the file doesn't exist or cannot be loaded """ try: - return Image.open(file_path) + img = Image.open(file_path) + img.load() # force decode now so the file handle is released (no ResourceWarning) + return img except FileNotFoundError: raise ValueError(f"Image file not found: {file_path}") except Exception as err: diff --git a/src/odl_renderer/types.py b/src/odl_renderer/types.py index b57e5e4..5d378f0 100644 --- a/src/odl_renderer/types.py +++ b/src/odl_renderer/types.py @@ -39,7 +39,8 @@ async def get_history( Mapping of entity_id to a list of state records, ordered oldest-first. Each record is a dict with at minimum: - "state": str — the state value (e.g. "23.5", "on") - - "last_changed": str — ISO 8601 timestamp + - "last_changed": str — ISO 8601 timestamp. Prefer a timezone-aware + value (with offset); a naive timestamp is interpreted as UTC. Entities with no data in the range may be absent or map to an empty list. """ ... diff --git a/src/odl_renderer/warmup.py b/src/odl_renderer/warmup.py index b5cf445..b5057a4 100644 --- a/src/odl_renderer/warmup.py +++ b/src/odl_renderer/warmup.py @@ -2,6 +2,10 @@ from __future__ import annotations +import logging + +_LOGGER = logging.getLogger(__name__) + _DEFAULT_SIZES = (16, 24, 32, 48, 64, 96) @@ -19,4 +23,6 @@ def warmup(sizes: tuple[int, ...] = _DEFAULT_SIZES) -> None: try: _get_mdi_font(size) except Exception: # noqa: BLE001 - break + # One bad size shouldn't skip pre-warming the rest. + _LOGGER.warning("Failed to pre-warm MDI font at size %s", size, exc_info=True) + continue diff --git a/tests/conftest.py b/tests/conftest.py index 004ec2e..0476d37 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,8 +1,9 @@ +from io import BytesIO from pathlib import Path import imagehash import pytest -from PIL import Image, ImageFont +from PIL import Image, ImageChops, ImageFont, UnidentifiedImageError from syrupy.extensions.image import PNGImageSnapshotExtension # Get package root @@ -10,6 +11,31 @@ ASSETS_DIR = PACKAGE_ROOT / "src" / "odl_renderer" / "assets" +class PixelPNGImageSnapshotExtension(PNGImageSnapshotExtension): + """PNG snapshot extension that matches on decoded pixels, not encoded bytes. + + The default image extension compares raw PNG bytes, so a Pillow/zlib upgrade + that re-encodes pixel-identical output into different bytes breaks every + snapshot (observed on main under Pillow 12). Decoding both sides and comparing + pixels keeps snapshots stable across encoder changes while still catching any + real visual difference. + """ + + def matches(self, *, serialized_data: object, snapshot_data: object) -> bool: + try: + new_img = Image.open(BytesIO(bytes(serialized_data))) # type: ignore[arg-type] + old_img = Image.open(BytesIO(bytes(snapshot_data))) # type: ignore[arg-type] + except (UnidentifiedImageError, OSError, TypeError, ValueError): + # Not decodable as images — fall back to the byte comparison. + return bool(serialized_data == snapshot_data) + if new_img.size != old_img.size: + return False + diff = ImageChops.difference(new_img.convert("RGBA"), old_img.convert("RGBA")) + # alpha_only=False so a difference in any RGB channel counts — the default + # (alpha-only) would ignore color changes on fully-opaque e-paper renders. + return diff.getbbox(alpha_only=False) is None + + @pytest.fixture def ppb_font(): """Provide path to bundled ppb font.""" @@ -50,4 +76,4 @@ def _compare(img1: Image.Image, img2: Image.Image, threshold: int = 5): @pytest.fixture def snapshot_png(snapshot): - return snapshot.use_extension(PNGImageSnapshotExtension) + return snapshot.use_extension(PixelPNGImageSnapshotExtension) diff --git a/tests/integration/test_plot.py b/tests/integration/test_plot.py index 1439aa5..2d00a61 100644 --- a/tests/integration/test_plot.py +++ b/tests/integration/test_plot.py @@ -198,6 +198,25 @@ async def test_plot_multiple_entities(self): ) assert isinstance(image, Image.Image) + async def test_plot_naive_timestamps(self): + """Naive (tz-less) last_changed values are treated as UTC, not crash (A12).""" + end = datetime.now(timezone.utc) + start = end - timedelta(hours=24) + step = timedelta(hours=2) + naive_states = [ + # isoformat() on a naive datetime has no offset suffix. + {"state": str(20 + i), "last_changed": (start + step * i).replace(tzinfo=None).isoformat()} + for i in range(12) + ] + provider = MockDataProvider({"sensor.temp": naive_states}) + image = await generate_image( + width=296, + height=128, + elements=[{"type": "plot", "data": [{"entity": "sensor.temp"}]}], + data_provider=provider, + ) + assert isinstance(image, Image.Image) + async def test_plot_span_gaps_numeric(self): """Time-based gap detection (span_gaps as seconds) doesn't crash.""" gapped = TEMP_STATES[:8] + TEMP_STATES[16:] # introduce a gap in the middle diff --git a/tests/unit/test_media_loader.py b/tests/unit/test_media_loader.py index 6660b86..e377435 100644 --- a/tests/unit/test_media_loader.py +++ b/tests/unit/test_media_loader.py @@ -63,6 +63,18 @@ async def test_file_path(self): result = await load_image(path) assert isinstance(result, Image.Image) + async def test_file_path_releases_handle(self): + """The file is decoded eagerly and its handle released (finding A12).""" + png_bytes = _make_png_bytes() + with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as f: + f.write(png_bytes) + path = f.name + result = await load_image(path) + # After .load(), Pillow drops the file pointer it owned. + assert getattr(result, "fp", None) is None + # The image is usable without touching the file again. + assert result.convert("RGBA").size == result.size + async def test_nonexistent_file_raises(self): with pytest.raises(ValueError, match="not found"): await load_image("/nonexistent/path/image.png") diff --git a/tests/unit/test_snapshot_extension.py b/tests/unit/test_snapshot_extension.py new file mode 100644 index 0000000..35a0984 --- /dev/null +++ b/tests/unit/test_snapshot_extension.py @@ -0,0 +1,46 @@ +"""Unit tests for the pixel-comparing PNG snapshot extension (finding C1).""" + +from io import BytesIO + +from PIL import Image + +from tests.conftest import PixelPNGImageSnapshotExtension + + +def _png(img: Image.Image, **save_kwargs) -> bytes: + buffer = BytesIO() + img.save(buffer, format="PNG", **save_kwargs) + return buffer.getvalue() + + +def _matches(a: bytes, b: bytes) -> bool: + # matches() only needs `self` for the (unused here) byte fallback. + ext = object.__new__(PixelPNGImageSnapshotExtension) + return ext.matches(serialized_data=a, snapshot_data=b) + + +def test_same_pixels_different_bytes_match(): + """Different PNG encodings of identical pixels must compare equal.""" + img = Image.new("RGBA", (20, 12), (10, 20, 30, 255)) + img.putpixel((5, 5), (200, 100, 50, 255)) + a = _png(img, compress_level=0) + b = _png(img, compress_level=9) + assert a != b # the encoders really did produce different bytes + assert _matches(a, b) is True + + +def test_different_pixels_do_not_match(): + a = _png(Image.new("RGBA", (20, 12), (0, 0, 0, 255))) + b = _png(Image.new("RGBA", (20, 12), (255, 255, 255, 255))) + assert _matches(a, b) is False + + +def test_different_size_does_not_match(): + a = _png(Image.new("RGBA", (20, 12), (0, 0, 0, 255))) + b = _png(Image.new("RGBA", (24, 12), (0, 0, 0, 255))) + assert _matches(a, b) is False + + +def test_non_image_falls_back_to_bytes(): + assert _matches(b"not-a-png", b"not-a-png") is True + assert _matches(b"not-a-png", b"other") is False diff --git a/tests/unit/test_warmup.py b/tests/unit/test_warmup.py new file mode 100644 index 0000000..f48bb63 --- /dev/null +++ b/tests/unit/test_warmup.py @@ -0,0 +1,21 @@ +"""Unit tests for warmup (finding A12: one bad size must not skip the rest).""" + +from odl_renderer.warmup import warmup + + +def test_warmup_continues_past_a_failing_size(monkeypatch): + attempted = [] + + def fake_get_mdi_font(size): + attempted.append(size) + if size == 24: + raise OSError("simulated font load failure") + return object() + + # warmup imports _get_mdi_font from elements.icons inside the function. + monkeypatch.setattr("odl_renderer.elements.icons._get_mdi_font", fake_get_mdi_font) + + warmup(sizes=(16, 24, 32)) + + # All sizes attempted despite 24 failing — no early break. + assert attempted == [16, 24, 32] diff --git a/tests/visual/__snapshots__/test_color_rendering/TestGrayColorVisualRegression.test_gray_text_inline_markup.png b/tests/visual/__snapshots__/test_color_rendering/TestGrayColorVisualRegression.test_gray_text_inline_markup.png index b132040..4ce7def 100644 Binary files a/tests/visual/__snapshots__/test_color_rendering/TestGrayColorVisualRegression.test_gray_text_inline_markup.png and b/tests/visual/__snapshots__/test_color_rendering/TestGrayColorVisualRegression.test_gray_text_inline_markup.png differ diff --git a/tests/visual/__snapshots__/test_layouts/TestLayoutVisualRegression.test_dashboard_layout.png b/tests/visual/__snapshots__/test_layouts/TestLayoutVisualRegression.test_dashboard_layout.png index 938060e..7d227c1 100644 Binary files a/tests/visual/__snapshots__/test_layouts/TestLayoutVisualRegression.test_dashboard_layout.png and b/tests/visual/__snapshots__/test_layouts/TestLayoutVisualRegression.test_dashboard_layout.png differ diff --git a/tests/visual/__snapshots__/test_plot_rendering/TestPlotVisualRegression.test_plot_right_legend.png b/tests/visual/__snapshots__/test_plot_rendering/TestPlotVisualRegression.test_plot_right_legend.png index f82a585..19d923c 100644 Binary files a/tests/visual/__snapshots__/test_plot_rendering/TestPlotVisualRegression.test_plot_right_legend.png and b/tests/visual/__snapshots__/test_plot_rendering/TestPlotVisualRegression.test_plot_right_legend.png differ diff --git a/tests/visual/__snapshots__/test_plot_rendering/TestPlotVisualRegression.test_plot_with_axes_and_legend.png b/tests/visual/__snapshots__/test_plot_rendering/TestPlotVisualRegression.test_plot_with_axes_and_legend.png index a0be38e..3ef0cc0 100644 Binary files a/tests/visual/__snapshots__/test_plot_rendering/TestPlotVisualRegression.test_plot_with_axes_and_legend.png and b/tests/visual/__snapshots__/test_plot_rendering/TestPlotVisualRegression.test_plot_with_axes_and_legend.png differ diff --git a/tests/visual/__snapshots__/test_text_rendering/TestTextVisualRegression.test_basic_text.png b/tests/visual/__snapshots__/test_text_rendering/TestTextVisualRegression.test_basic_text.png index 77702bc..a826d6e 100644 Binary files a/tests/visual/__snapshots__/test_text_rendering/TestTextVisualRegression.test_basic_text.png and b/tests/visual/__snapshots__/test_text_rendering/TestTextVisualRegression.test_basic_text.png differ diff --git a/tests/visual/__snapshots__/test_text_rendering/TestTextVisualRegression.test_text_colors.png b/tests/visual/__snapshots__/test_text_rendering/TestTextVisualRegression.test_text_colors.png index 38df5e4..e823357 100644 Binary files a/tests/visual/__snapshots__/test_text_rendering/TestTextVisualRegression.test_text_colors.png and b/tests/visual/__snapshots__/test_text_rendering/TestTextVisualRegression.test_text_colors.png differ diff --git a/tests/visual/__snapshots__/test_text_rendering/TestTextVisualRegression.test_text_sizes[12].png b/tests/visual/__snapshots__/test_text_rendering/TestTextVisualRegression.test_text_sizes[12].png index 840830e..ce073a0 100644 Binary files a/tests/visual/__snapshots__/test_text_rendering/TestTextVisualRegression.test_text_sizes[12].png and b/tests/visual/__snapshots__/test_text_rendering/TestTextVisualRegression.test_text_sizes[12].png differ diff --git a/tests/visual/__snapshots__/test_text_rendering/TestTextVisualRegression.test_text_sizes[16].png b/tests/visual/__snapshots__/test_text_rendering/TestTextVisualRegression.test_text_sizes[16].png index ff0c733..d98d168 100644 Binary files a/tests/visual/__snapshots__/test_text_rendering/TestTextVisualRegression.test_text_sizes[16].png and b/tests/visual/__snapshots__/test_text_rendering/TestTextVisualRegression.test_text_sizes[16].png differ diff --git a/tests/visual/__snapshots__/test_text_rendering/TestTextVisualRegression.test_text_sizes[24].png b/tests/visual/__snapshots__/test_text_rendering/TestTextVisualRegression.test_text_sizes[24].png index df2fc87..8832177 100644 Binary files a/tests/visual/__snapshots__/test_text_rendering/TestTextVisualRegression.test_text_sizes[24].png and b/tests/visual/__snapshots__/test_text_rendering/TestTextVisualRegression.test_text_sizes[24].png differ diff --git a/tests/visual/__snapshots__/test_text_rendering/TestTextVisualRegression.test_text_sizes[32].png b/tests/visual/__snapshots__/test_text_rendering/TestTextVisualRegression.test_text_sizes[32].png index 0e066f1..7205964 100644 Binary files a/tests/visual/__snapshots__/test_text_rendering/TestTextVisualRegression.test_text_sizes[32].png and b/tests/visual/__snapshots__/test_text_rendering/TestTextVisualRegression.test_text_sizes[32].png differ diff --git a/tests/visual/__snapshots__/test_transform_rendering/TestTransformVisualRegression.test_rotated_text.png b/tests/visual/__snapshots__/test_transform_rendering/TestTransformVisualRegression.test_rotated_text.png index 7b8aeeb..378bd3b 100644 Binary files a/tests/visual/__snapshots__/test_transform_rendering/TestTransformVisualRegression.test_rotated_text.png and b/tests/visual/__snapshots__/test_transform_rendering/TestTransformVisualRegression.test_rotated_text.png differ diff --git a/tests/visual/__snapshots__/test_transform_rendering/TestTransformVisualRegression.test_rotated_text_pivot_left_top.png b/tests/visual/__snapshots__/test_transform_rendering/TestTransformVisualRegression.test_rotated_text_pivot_left_top.png index ae22beb..b491647 100644 Binary files a/tests/visual/__snapshots__/test_transform_rendering/TestTransformVisualRegression.test_rotated_text_pivot_left_top.png and b/tests/visual/__snapshots__/test_transform_rendering/TestTransformVisualRegression.test_rotated_text_pivot_left_top.png differ