"""Юнит-тесты для renderer.py. Покрывают: - render_png() с валидным SVG → либо PNG bytes, либо None (если cairo недоступен) Документируем: функция НЕ бросает — всегда возвращает bytes | None. - render_png() с пустым/None входом → None - render_png() с невалидным SVG (синтаксически сломан) → None (cairo упадёт, renderer ловит) - render_png() с SVG без xmlns → корректно добавляет xmlns (cairo требует) - save_png() создаёт каталог при отсутствии и пишет файл с ожидаемым путём - save_png() с произвольными PNG-байтами сохраняет as-is (без декодирования) - Интеграция: render_png + save_png end-to-end (если cairo есть) либо save_png-fallback (если cairo нет) ЗАМЕЧАНИЕ ПОВЕДЕНИЯ (документируем в этом тесте): Если `cairo-2.dll`/`libcairo` недоступен — `render_png()` возвращает `None`, не бросает. Это явный контракт из дизайн-доки §6: "Если cairo недоступен … `render_png()` возвращает `None`". Невалидный SVG: если cairo есть — cairosvg бросит Exception, renderer ловит его через `except Exception` и возвращает `None`. Если cairo нет — мы и так сразу возвращаем `None`. Запуск: `python -m pytest tests/test_renderer.py -v` """ from __future__ import annotations import io import sys import tempfile from pathlib import Path from unittest.mock import patch ROOT = Path(__file__).resolve().parent.parent if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) import pytest # noqa: E402 from renderer import _get_cairosvg, render_png, save_png # noqa: E402 # --------------------------------------------------------------------------- # Документация поведения: пустой/None вход # --------------------------------------------------------------------------- def test_render_png_empty_string_returns_none(): """Пустая строка → None, без исключений.""" assert render_png("") is None def test_render_png_whitespace_only_returns_none(): """Строка из пробелов → None.""" assert render_png(" \n \t ") is None # --------------------------------------------------------------------------- # Документация поведения: валидный SVG # --------------------------------------------------------------------------- VALID_ICON_SVG = ( '' '' '' ) def test_render_png_valid_svg_returns_png_or_none(): """Контракт: возвращает либо bytes, либо None. Если cairo установлен — bytes (PNG, начинается с magic bytes). Если cairo недоступен — None. В обоих случаях НЕ бросает. """ result = render_png(VALID_ICON_SVG, size=(64, 64)) if result is None: # cairo недоступен — это допустимо, см. дизайн-док §6. assert _get_cairosvg() is None, ( "_get_cairosvg() вернул модуль, но render_png() вернул None" ) else: # cairo есть — должны получить валидный PNG. assert isinstance(result, bytes) assert len(result) > 0 # PNG magic: 89 50 4E 47 0D 0A 1A 0A assert result.startswith(b"\x89PNG\r\n\x1a\n"), ( f"вывод render_png() не начинается с PNG magic: {result[:16]!r}" ) def test_render_png_uses_size_width_as_output(): """Если cairo доступен, проверяем output_width через прямой вызов.""" cairosvg = _get_cairosvg() if cairosvg is None: pytest.skip("cairo недоступен — нельзя проверить output_width") result = render_png(VALID_ICON_SVG, size=(256, 256)) assert result is not None # Проверяем, что PNG имеет ширину 256 (viewBox 64x64 → квадрат 256x256) from PIL import Image img = Image.open(io.BytesIO(result)) assert img.size == (256, 256), f"ожидался 256x256, получено {img.size}" # --------------------------------------------------------------------------- # Документация поведения: невалидный SVG # --------------------------------------------------------------------------- def test_render_png_invalid_svg_returns_none_or_raises_cairo(): """Невалидный SVG → либо None, либо cairosvg бросает. Наш контракт: render_png() ВСЕГДА возвращает bytes|None, не бросает наружу. Это валидируется в `test_render_png_does_not_propagate_cairo_exceptions` (через mock). """ broken = '' # Независимо от состояния cairo, не должны получить необработанный raise наружу. result = render_png(broken, size=(64, 64)) assert result is None # либо cairo нет, либо cairo бросил и мы вернули None def test_render_png_does_not_propagate_renderer_exceptions(): """Даже если все бэкенды бросают — render_png() возвращает None, а не raise. Сейчас renderer использует resvg-py как первый бэкенд, cairosvg — fallback. Мокаем ОБА, чтобы render_png дошёл до конца и вернул None без exception. """ class FakeRenderer: @staticmethod def svg_to_bytes(**kwargs): raise RuntimeError("simulated resvg failure") class FakeCairo: @staticmethod def svg2png(**kwargs): raise RuntimeError("simulated cairo failure") with patch("renderer._get_resvg", return_value=FakeRenderer()), \ patch("renderer._get_cairosvg", return_value=FakeCairo()): result = render_png(VALID_ICON_SVG, size=(64, 64)) assert result is None # --------------------------------------------------------------------------- # xmlns-инъекция # --------------------------------------------------------------------------- def test_render_png_adds_xmlns_if_missing(): """Если модель не поставила xmlns, renderer добавляет его в первую .""" # Прямо проверяем _ensure_xmlns (внутренняя, но контрактная) from renderer import _ensure_xmlns no_xmlns = '' fixed = _ensure_xmlns(no_xmlns) assert 'xmlns="http://www.w3.org/2000/svg"' in fixed.split(">", 1)[0] already = '' assert _ensure_xmlns(already) == already # В первом теге xmlns, но в href — не считается (спека смотрит ТОЛЬКО в head) in_attr = '' fixed_in_attr = _ensure_xmlns(in_attr) head = fixed_in_attr.split(">", 1)[0] assert 'xmlns="http://www.w3.org/2000/svg"' in head # --------------------------------------------------------------------------- # save_png() # --------------------------------------------------------------------------- def test_save_png_creates_directory_and_writes_file(tmp_path: Path): """save_png создаёт каталог и пишет файл с ожидаемым именем.""" fake_png = b"\x89PNG\r\n\x1a\n" + b"\x00" * 100 # валидный префикс + мусор previews_dir = tmp_path / "previews" out = save_png( fake_png, previews_dir=previews_dir, record_id=42, candidate_index=0, ) assert out.exists() assert out.is_file() assert out == previews_dir / "42_0.png" assert out.read_bytes() == fake_png def test_save_png_does_not_decode_or_validate_png_bytes(tmp_path: Path): """save_png пишет байты as-is — никаких проверок PNG-формата.""" previews_dir = tmp_path / "p" # Мусорные байты — не PNG, но save_png это не волнует. out = save_png( b"not actually png", previews_dir=previews_dir, record_id=1, candidate_index=3, ) assert out == previews_dir / "1_3.png" assert out.read_bytes() == b"not actually png" def test_save_png_appends_filename_with_zero_padded_index(tmp_path: Path): """record_id=5, candidate_index=7 → '5_7.png'.""" previews_dir = tmp_path / "p" out = save_png(b"x", previews_dir=previews_dir, record_id=5, candidate_index=7) assert out.name == "5_7.png" def test_save_png_overwrites_existing_file(tmp_path: Path): """Повторный save с тем же (record_id, candidate_index) перезаписывает.""" previews_dir = tmp_path / "p" out1 = save_png(b"first", previews_dir=previews_dir, record_id=1, candidate_index=0) out2 = save_png(b"second", previews_dir=previews_dir, record_id=1, candidate_index=0) assert out1 == out2 assert out1.read_bytes() == b"second" # --------------------------------------------------------------------------- # Интеграция: render + save (если cairo есть) # --------------------------------------------------------------------------- def test_render_and_save_integration(tmp_path: Path): """Сквозной кейс: render → save → файл существует и непустой.""" result = render_png(VALID_ICON_SVG, size=(64, 64)) if result is None: pytest.skip("cairo недоступен — интеграционный тест render+save пропущен") out = save_png( result, previews_dir=tmp_path / "previews", record_id=10, candidate_index=0, ) assert out.exists() assert out.stat().st_size > 0 # Содержимое — валидный PNG from PIL import Image img = Image.open(out) img.verify() # поднимает, если битый PNG # --------------------------------------------------------------------------- # Граничный случай: путь с пробелами / Unicode # --------------------------------------------------------------------------- def test_save_png_works_with_unicode_in_path(tmp_path: Path): """save_png принимает каталог с юникодом (актуально для Windows).""" previews_dir = tmp_path / "превью" # кириллица out = save_png(b"x", previews_dir=previews_dir, record_id=1, candidate_index=0) assert out.exists() assert out.name == "1_0.png"