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
This commit is contained in:
Mavis
2026-06-13 15:32:54 +03:00
commit 2394eff1c0
21 changed files with 5116 additions and 0 deletions
+213
View File
@@ -0,0 +1,213 @@
"""Юнит-тесты для 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 == ""