Files
omnisvg-lite/tests/test_renderer.py
T
Mavis 17a114e015 Add incremental_svg parser (TDD) + fix pre-existing test regressions
New:
- incremental_svg.py: parse_to_valid(prefix) handles any partial SVG
  - Closes open tags, opens quotes, truncates partial tags
  - Strips reasoning text and markdown fences from prefix
  - stream_to_valid(chunks) generator for incremental updates
- tests/test_incremental_svg.py: 34 test cases including property-based

Fixed (regressions from earlier today):
- test_app.py: pass base_url and api_key to on_generate (new UI fields)
- test_app.py: on_mode_change defaults updated to n=1
- test_renderer.py: mock both resvg and cairosvg since resvg is now primary
- (manual fixes I did as owner, before letting streaming-client start)

All tests: 157 passed, 1 skipped, 0 failed
2026-06-13 15:54:11 +03:00

257 lines
11 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Юнит-тесты для 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 = (
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">'
'<circle cx="32" cy="32" r="20" fill="#FF0000"/>'
'</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 = '<svg xmlns="http://www.w3.org/2000/svg"><circle cx="32" cy="32" r="999999999" fill="oops"/></svg>'
# Независимо от состояния 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 добавляет его в первую <svg>."""
# Прямо проверяем _ensure_xmlns (внутренняя, но контрактная)
from renderer import _ensure_xmlns
no_xmlns = '<svg viewBox="0 0 64 64"><rect/></svg>'
fixed = _ensure_xmlns(no_xmlns)
assert 'xmlns="http://www.w3.org/2000/svg"' in fixed.split(">", 1)[0]
already = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"></svg>'
assert _ensure_xmlns(already) == already
# В первом теге xmlns, но в href — не считается (спека смотрит ТОЛЬКО в head)
in_attr = '<svg viewBox="0 0 64 64" href="http://x.com"><rect/></svg>'
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"