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
214 lines
7.1 KiB
Python
214 lines
7.1 KiB
Python
"""Юнит-тесты для 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 == ""
|