2394eff1c0
- 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
249 lines
11 KiB
Python
249 lines
11 KiB
Python
"""Юнит-тесты для 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"
|