Files
omnisvg-lite/tests/test_renderer.py
T
Mavis 2394eff1c0 Initial commit: OmniSVG-Lite MVP before live-streaming work
- LM Studio client (httpx-based, OpenAI-compatible)
- SVG validator (lxml, whitelist tags, no <script>/<foreignObject>/http refs)
- PNG renderer (resvg-py primary, cairosvg fallback - no native cairo dep)
- History (SQLite, tracks raw/validated/preview paths)
- Gradio UI on 127.0.0.1:8788 with:
  * mode radio (icon/illustration)
  * n_candidates slider (default 1)
  * image upload for image-to-SVG
  * LM Studio URL/token inputs
  * model dropdown + refresh button
- prompts/ with system_icon.txt, system_illustration.txt, few_shot_examples.txt
- docs/spec.md, docs/design.md
- 122 unit/integration tests passing
2026-06-13 15:32:54 +03:00

249 lines
11 KiB
Python
Raw 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_cairo_exceptions():
"""Даже если cairosvg бросает — render_png() возвращает None, а не raise."""
fake_cairosvg = type("Fake", (), {})()
# Создаём фейк-модуль, у которого svg2png бросает
class FakeCairo:
@staticmethod
def svg2png(**kwargs):
raise RuntimeError("simulated cairo failure")
fake = FakeCairo()
with patch("renderer._get_cairosvg", return_value=fake):
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"