Files
omnisvg-lite/tests/test_validator.py
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

214 lines
7.1 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.
"""Юнит-тесты для validator.py.
Покрывают контрактные правила из design.md §5 и явные требования задачи:
- валидный SVG → ok=True
- отсутствует viewBox → ok=False (missing_viewbox)
- есть <script> → ok=False (disallowed_tag)
- есть <foreignObject> → ok=False (disallowed_tag)
- http:// ссылка в href → ok=False (external_ref)
- on*= атрибут → ok=False (event_handler)
Плюс несколько дополнительных кейсов, чтобы покрыть size-limit, парсинг
из обёртки с пояснением и edge cases.
Запуск:
python -m pytest tests/ -v
"""
from __future__ import annotations
import sys
from pathlib import Path
# Делаем корень проекта доступным как `import validator`.
ROOT = Path(__file__).resolve().parent.parent
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
import pytest # noqa: E402
from validator import ( # noqa: E402
MAX_BYTES,
validate,
validate_svg,
extract_svg,
ValidatorError,
)
# ---------------------------------------------------------------------------
# Положительные кейсы
# ---------------------------------------------------------------------------
VALID_ICON = """<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
<circle cx="26" cy="26" r="14" fill="#3B82F6"/>
<rect x="36" y="34" width="6" height="20" rx="3" fill="#3B82F6" transform="rotate(45 39 44)"/>
</svg>"""
def test_valid_svg_returns_ok_and_cleaned():
ok, reason, cleaned = validate_svg(VALID_ICON, mode="icon")
assert ok is True
assert reason == ""
assert "<svg" in cleaned
assert cleaned.endswith("</svg>")
# viewBox должен сохраниться после сериализации.
assert 'viewBox="0 0 64 64"' in cleaned
def test_valid_svg_wrapped_in_prose_is_extracted():
"""Модель иногда отвечает `Here is your icon: <svg>...</svg>`. Должны вытащить."""
wrapped = (
"Sure! Here is your SVG icon:\n"
f"{VALID_ICON}\n"
"Hope that helps."
)
ok, reason, cleaned = validate_svg(wrapped, mode="icon")
assert ok is True, f"expected ok, got reason={reason!r}"
assert reason == ""
assert "<svg" in cleaned
def test_valid_illustration_with_filter():
"""В illustration разрешены <filter>, <feGaussianBlur> и т.п."""
ill = """<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
<defs>
<filter id="b" x="-10%" y="-10%" width="120%" height="120%">
<feGaussianBlur stdDeviation="2"/>
</filter>
</defs>
<rect x="100" y="100" width="200" height="200" fill="#ff0000" filter="url(#b)"/>
</svg>"""
ok, reason, _ = validate_svg(ill, mode="illustration")
assert ok is True, f"reason={reason!r}"
# ---------------------------------------------------------------------------
# Негативные кейсы
# ---------------------------------------------------------------------------
def test_missing_viewbox_rejected():
bad = """<svg xmlns="http://www.w3.org/2000/svg">
<circle cx="10" cy="10" r="5"/>
</svg>"""
ok, reason, cleaned = validate_svg(bad, mode="icon")
assert ok is False
assert reason == "missing_viewbox"
assert cleaned == ""
def test_bad_viewbox_rejected():
bad = """<svg xmlns="http://www.w3.org/2000/svg" viewBox="oops">
<circle cx="10" cy="10" r="5"/>
</svg>"""
ok, reason, _ = validate_svg(bad, mode="icon")
assert ok is False
assert reason == "bad_viewbox"
def test_script_tag_rejected():
bad = """<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<script>alert(1)</script>
</svg>"""
ok, reason, _ = validate_svg(bad, mode="icon")
assert ok is False
assert reason == "disallowed_tag"
def test_foreign_object_rejected():
bad = """<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<foreignObject width="100" height="100">
<div xmlns="http://www.w3.org/1999/xhtml">Hi</div>
</foreignObject>
</svg>"""
ok, reason, _ = validate_svg(bad, mode="icon")
assert ok is False
assert reason == "disallowed_tag"
def test_http_ref_rejected():
bad = """<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<use href="http://example.com/sprite.svg#icon"/>
</svg>"""
ok, reason, _ = validate_svg(bad, mode="icon")
assert ok is False
assert reason == "external_ref"
def test_onclick_attribute_rejected():
bad = """<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect x="0" y="0" width="10" height="10" onclick="alert(1)"/>
</svg>"""
ok, reason, _ = validate_svg(bad, mode="icon")
assert ok is False
assert reason == "event_handler"
def test_url_with_https_rejected():
"""https:// в url(...) тоже блокируется."""
bad = """<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<defs>
<pattern id="p" width="4" height="4" patternUnits="userSpaceOnUse">
<rect width="4" height="4" fill="url('https://example.com/tex.png')"/>
</pattern>
</defs>
</svg>"""
ok, reason, _ = validate_svg(bad, mode="icon")
assert ok is False
# Сейчас external_ref ловится на `url(` независимо от http(s).
assert reason in ("external_ref", "disallowed_tag", "unknown_tag")
def test_too_large_rejected():
"""SVG > MAX_BYTES[mode] → too_large."""
# Соберём раздутый SVG с большим комментарием.
pad = "<!-- " + ("x" * (MAX_BYTES["icon"] + 100)) + " -->"
bad = (
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">'
+ pad
+ "</svg>"
)
ok, reason, _ = validate_svg(bad, mode="icon")
assert ok is False
assert reason == "too_large"
def test_empty_input_rejected():
ok, reason, _ = validate_svg("", mode="icon")
assert ok is False
assert reason == "empty_input"
def test_no_svg_block_rejected():
ok, reason, _ = validate_svg("Sorry, I cannot help with that.", mode="icon")
assert ok is False
assert reason == "not_svg"
# ---------------------------------------------------------------------------
# Прямые проверки API (validate() бросает, extract_svg() утилитарный)
# ---------------------------------------------------------------------------
def test_validate_strict_raises():
with pytest.raises(ValidatorError) as excinfo:
validate("plain text", mode="icon")
assert excinfo.value.code == "not_svg"
def test_extract_svg_finds_block():
text = "noise <svg viewBox='0 0 1 1'></svg> noise"
found = extract_svg(text)
assert found.startswith("<svg")
assert found.endswith("</svg>")
def test_validate_svg_returns_tuple_for_unexpected_exception():
"""Даже если что-то странное, обёртка возвращает кортеж (ok=False, ...)."""
ok, reason, cleaned = validate_svg(None, mode="icon") # type: ignore[arg-type]
assert ok is False
assert reason in ("empty_input", "internal:TypeError")
assert cleaned == ""