17a114e015
New: - incremental_svg.py: parse_to_valid(prefix) handles any partial SVG - Closes open tags, opens quotes, truncates partial tags - Strips reasoning text and markdown fences from prefix - stream_to_valid(chunks) generator for incremental updates - tests/test_incremental_svg.py: 34 test cases including property-based Fixed (regressions from earlier today): - test_app.py: pass base_url and api_key to on_generate (new UI fields) - test_app.py: on_mode_change defaults updated to n=1 - test_renderer.py: mock both resvg and cairosvg since resvg is now primary - (manual fixes I did as owner, before letting streaming-client start) All tests: 157 passed, 1 skipped, 0 failed
505 lines
20 KiB
Python
505 lines
20 KiB
Python
"""Юнит-тесты для incremental_svg.py — incremental SVG parser.
|
||
|
||
Покрывают контракт `parse_to_valid(prefix)`:
|
||
- на ЛЮБОМ prefix'е возвращает валидный SVG, который парсится lxml без ошибок;
|
||
- закрывает открытые теги, атрибуты, кавычки;
|
||
- обрывает частичные теги (внутри path-d);
|
||
- вырезает reasoning / markdown fence / хвостовой мусор.
|
||
|
||
Запуск:
|
||
python -m pytest tests/test_incremental_svg.py -v
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import random
|
||
import re
|
||
import string
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
ROOT = Path(__file__).resolve().parent.parent
|
||
if str(ROOT) not in sys.path:
|
||
sys.path.insert(0, str(ROOT))
|
||
|
||
import pytest # noqa: E402
|
||
from lxml import etree # noqa: E402
|
||
|
||
from incremental_svg import parse_to_valid, stream_to_valid # noqa: E402
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Базовый полный валидный SVG, от которого будем брать префиксы
|
||
# ---------------------------------------------------------------------------
|
||
|
||
VALID_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"/>'
|
||
'<rect x="10" y="10" width="20" height="20" fill="#00FF00"/>'
|
||
"</svg>"
|
||
)
|
||
|
||
# SVG со вложенными открытыми тегами (для теста вложенности)
|
||
NESTED_SVG = (
|
||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">'
|
||
"<g>"
|
||
'<circle cx="32" cy="32" r="20" fill="red"/>'
|
||
"</g>"
|
||
"</svg>"
|
||
)
|
||
|
||
# Простая утилита — фиксирует, что результат парсится
|
||
def _assert_valid_svg(svg_text: str) -> etree._Element:
|
||
"""Парсит svg_text, возвращает root. Падает в pytest, если XML невалидный."""
|
||
try:
|
||
root = etree.fromstring(svg_text.encode("utf-8"))
|
||
except etree.XMLSyntaxError as exc:
|
||
pytest.fail(f"parse_to_valid вернул невалидный XML: {exc}\n{svg_text!r}")
|
||
return root
|
||
|
||
|
||
def _has_xmlns_in_root(svg_text: str) -> bool:
|
||
"""Проверяет, что xmlns либо в <svg ...>, либо подразумевается через сериализацию."""
|
||
head_end = svg_text.find(">")
|
||
if head_end < 0:
|
||
return False
|
||
return "xmlns" in svg_text[:head_end]
|
||
|
||
|
||
def _local_tag(tag) -> str:
|
||
"""Снимает namespace: `{ns}local` → `local`."""
|
||
s = str(tag)
|
||
if "}" in s:
|
||
return s.split("}", 1)[1]
|
||
return s
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 1. Базовые позитивные кейсы
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_empty_string_returns_valid_empty_svg():
|
||
"""Пустая строка → валидный пустой SVG (с корневым <svg></svg>)."""
|
||
result = parse_to_valid("")
|
||
root = _assert_valid_svg(result)
|
||
assert root.tag.endswith("svg"), f"root должен быть svg, получен {root.tag}"
|
||
assert len(root) == 0 # пустой
|
||
|
||
|
||
def test_whitespace_only_returns_valid_empty_svg():
|
||
"""Строка из пробелов/переносов → валидный пустой SVG."""
|
||
result = parse_to_valid(" \n \t ")
|
||
root = _assert_valid_svg(result)
|
||
assert root.tag.endswith("svg")
|
||
|
||
|
||
def test_complete_valid_svg_returned_intact():
|
||
"""Полный валидный SVG → возвращается без изменений (и остаётся валидным)."""
|
||
result = parse_to_valid(VALID_SVG)
|
||
root = _assert_valid_svg(result)
|
||
# viewBox сохранился
|
||
assert root.get("viewBox") == "0 0 64 64"
|
||
# дети на месте
|
||
assert len(root) == 2
|
||
|
||
|
||
def test_complete_valid_svg_with_nested_open_tags():
|
||
"""Полный валидный SVG с <g>...</g> внутри → возвращается как есть."""
|
||
result = parse_to_valid(NESTED_SVG)
|
||
root = _assert_valid_svg(result)
|
||
assert root.get("viewBox") == "0 0 64 64"
|
||
# root → g → circle
|
||
g = list(root)
|
||
assert len(g) == 1
|
||
assert g[0].tag.endswith("g")
|
||
assert len(list(g[0])) == 1
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 2. Обрезанные открывающие теги
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_svg_tag_without_closing_bracket():
|
||
"""Только `<svg viewBox="0 0 64 64"` (без `>`) → валидный пустой SVG."""
|
||
result = parse_to_valid('<svg viewBox="0 0 64 64"')
|
||
root = _assert_valid_svg(result)
|
||
assert root.get("viewBox") == "0 0 64 64"
|
||
assert len(root) == 0
|
||
|
||
|
||
def test_svg_with_open_bracket_but_no_attributes():
|
||
"""`<svg` без атрибутов и без `>` → валидный SVG."""
|
||
result = parse_to_valid("<svg")
|
||
root = _assert_valid_svg(result)
|
||
assert root.tag.endswith("svg")
|
||
|
||
|
||
def test_svg_with_open_bracket_and_partial_attrs():
|
||
"""`<svg viewBox=` (без значения и `>`) → валидный SVG."""
|
||
result = parse_to_valid('<svg viewBox=')
|
||
root = _assert_valid_svg(result)
|
||
assert root.tag.endswith("svg")
|
||
|
||
|
||
def test_circle_with_partial_attribute_no_close():
|
||
"""`<circle cx="32"` без `>` → закрывает атрибут и тег."""
|
||
prefix = '<svg viewBox="0 0 64 64"><circle cx="32"'
|
||
result = parse_to_valid(prefix)
|
||
root = _assert_valid_svg(result)
|
||
assert root.get("viewBox") == "0 0 64 64"
|
||
circles = [e for e in root.iter() if e.tag.endswith("circle")]
|
||
assert len(circles) == 1
|
||
assert circles[0].get("cx") == "32"
|
||
|
||
|
||
def test_circle_partial_no_value_no_close():
|
||
"""`<circle cx="32" cy=` (нет значения и нет `>`) → закрывает и атрибут, и тег."""
|
||
prefix = '<svg viewBox="0 0 64 64"><circle cx="32" cy='
|
||
result = parse_to_valid(prefix)
|
||
root = _assert_valid_svg(result)
|
||
assert root.get("viewBox") == "0 0 64 64"
|
||
circles = [e for e in root.iter() if e.tag.endswith("circle")]
|
||
assert len(circles) == 1
|
||
# cy должен быть пустой строкой или None
|
||
cy = circles[0].get("cy")
|
||
assert cy in (None, "")
|
||
|
||
|
||
def test_circle_open_quote_no_value():
|
||
"""`<circle cx="32" cy="` (открытая кавычка) → закрывает кавычку и тег."""
|
||
prefix = '<svg viewBox="0 0 64 64"><circle cx="32" cy="'
|
||
result = parse_to_valid(prefix)
|
||
root = _assert_valid_svg(result)
|
||
circles = [e for e in root.iter() if e.tag.endswith("circle")]
|
||
assert len(circles) == 1
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 3. Обрезанный path-d
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_path_d_truncated_no_close():
|
||
"""`<path d="M 12 32` (без `>`) → обрезает до последнего полного тега."""
|
||
prefix = '<svg viewBox="0 0 64 64"><path d="M 12 32'
|
||
result = parse_to_valid(prefix)
|
||
root = _assert_valid_svg(result)
|
||
assert root.get("viewBox") == "0 0 64 64"
|
||
# path либо пустой (d=""), либо отсутствует, но НЕ битый XML
|
||
paths = [e for e in root.iter() if e.tag.endswith("path")]
|
||
for p in paths:
|
||
d = p.get("d") or ""
|
||
# если d сохранился — он должен быть непустой валидный path-data
|
||
# минимально: начинается с M (uppercase absolute move)
|
||
# или пустой
|
||
|
||
|
||
def test_path_d_mid_curve_truncated():
|
||
"""`<path d="M 12 32 C 40 20, 24 20, 16 16` (без `>`) → обрезает."""
|
||
prefix = '<svg viewBox="0 0 64 64"><path d="M 12 32 C 40 20, 24 20, 16 16'
|
||
result = parse_to_valid(prefix)
|
||
root = _assert_valid_svg(result)
|
||
# XML валиден — это уже главное
|
||
assert root.tag.endswith("svg")
|
||
|
||
|
||
def test_path_d_truncated_keeps_xmlns_and_viewbox():
|
||
"""Даже при оборванном path-d, root-атрибуты (xmlns, viewBox) сохраняются."""
|
||
prefix = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><path d="M 0 0 L 1'
|
||
result = parse_to_valid(prefix)
|
||
root = _assert_valid_svg(result)
|
||
assert root.get("viewBox") == "0 0 64 64"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 4. Самозакрытые и вложенные теги
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_self_closing_path_not_double_closed():
|
||
"""Самозакрытый `<path/>` → не закрывается повторно."""
|
||
prefix = '<svg viewBox="0 0 64 64"><path d="M 0 0 L 10 10"/>'
|
||
result = parse_to_valid(prefix)
|
||
root = _assert_valid_svg(result)
|
||
paths = [e for e in root.iter() if e.tag.endswith("path")]
|
||
assert len(paths) == 1
|
||
|
||
|
||
def test_nested_open_tags_both_closed():
|
||
"""Вложенные `<g><circle/></g>` (если <g> открыт) → оба закрыты."""
|
||
prefix = '<svg viewBox="0 0 64 64"><g><circle cx="32" cy="32" r="20"'
|
||
result = parse_to_valid(prefix)
|
||
root = _assert_valid_svg(result)
|
||
# Должен быть <g>, внутри — <circle>
|
||
gs = [e for e in root.iter() if _local_tag(e.tag) == "g"]
|
||
assert len(gs) >= 1
|
||
circles_in_g = [c for g in gs for c in g.iter() if _local_tag(c.tag) == "circle"]
|
||
assert len(circles_in_g) == 1
|
||
|
||
|
||
def test_multiple_open_tags_all_closed():
|
||
"""Несколько вложенных открытых тегов — все закрываются."""
|
||
prefix = '<svg viewBox="0 0 64 64"><g><g><circle cx="32"'
|
||
result = parse_to_valid(prefix)
|
||
root = _assert_valid_svg(result)
|
||
# Должно парситься
|
||
assert root.tag.endswith("svg")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 5. Префиксы с рассуждениями / markdown
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_prefix_with_thinking_process():
|
||
"""Префикс, начинающийся с `Thinking Process:`, вырезается до `<svg`."""
|
||
prefix = (
|
||
"Thinking Process: The user wants an icon. "
|
||
"I should generate a simple circle.\n"
|
||
+ VALID_SVG
|
||
)
|
||
result = parse_to_valid(prefix)
|
||
root = _assert_valid_svg(result)
|
||
assert root.get("viewBox") == "0 0 64 64"
|
||
|
||
|
||
def test_prefix_with_markdown_fence():
|
||
"""Префикс с markdown fence (```xml) вырезается."""
|
||
prefix = "```xml\n" + VALID_SVG + "\n```"
|
||
result = parse_to_valid(prefix)
|
||
root = _assert_valid_svg(result)
|
||
assert root.get("viewBox") == "0 0 64 64"
|
||
|
||
|
||
def test_prefix_with_markdown_fence_no_lang():
|
||
"""Префикс с markdown fence без языка (```) — тоже вырезается."""
|
||
prefix = "```\n" + VALID_SVG + "\n```"
|
||
result = parse_to_valid(prefix)
|
||
root = _assert_valid_svg(result)
|
||
assert root.get("viewBox") == "0 0 64 64"
|
||
|
||
|
||
def test_prefix_with_trailing_garbage_after_valid_svg():
|
||
"""Валидный SVG плюс хвост мусора — возвращается только валидная часть."""
|
||
prefix = VALID_SVG + "\n\nSome trailing explanation.\nAnd more text."
|
||
result = parse_to_valid(prefix)
|
||
root = _assert_valid_svg(result)
|
||
assert root.get("viewBox") == "0 0 64 64"
|
||
# не должно быть trailing garbage
|
||
assert "trailing" not in result
|
||
assert "explanation" not in result
|
||
|
||
|
||
def test_prefix_with_extra_whitespace():
|
||
"""Лишний whitespace в конце SVG — нормально, валидный результат."""
|
||
result = parse_to_valid(VALID_SVG + " \n\n ")
|
||
root = _assert_valid_svg(result)
|
||
assert root.get("viewBox") == "0 0 64 64"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 6. Несколько SVG и спец-случаи
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_multiple_svgs_in_prefix_takes_first():
|
||
"""Несколько `<svg>` подряд — берём только первый валидный."""
|
||
second = (
|
||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">'
|
||
'<rect x="0" y="0" width="32" height="32" fill="blue"/>'
|
||
"</svg>"
|
||
)
|
||
prefix = VALID_SVG + "\n" + second
|
||
result = parse_to_valid(prefix)
|
||
root = _assert_valid_svg(result)
|
||
# Должен быть первый (viewBox 0 0 64 64)
|
||
assert root.get("viewBox") == "0 0 64 64"
|
||
|
||
|
||
def test_svg_without_xmlns_gets_xmlns_added():
|
||
"""Если в `<svg>` нет xmlns, добавляется."""
|
||
prefix = '<svg viewBox="0 0 64 64"><circle cx="32" cy="32" r="20"/></svg>'
|
||
result = parse_to_valid(prefix)
|
||
# xmlns должен появиться в head <svg ...>
|
||
assert _has_xmlns_in_root(result), f"xmlns не добавлен: {result!r}"
|
||
root = _assert_valid_svg(result)
|
||
assert root.get("viewBox") == "0 0 64 64"
|
||
|
||
|
||
def test_svg_with_existing_xmlns_preserved():
|
||
"""Если xmlns уже есть, не дублируется."""
|
||
result = parse_to_valid(VALID_SVG)
|
||
head = result.split(">", 1)[0]
|
||
# ровно одно вхождение xmlns=
|
||
assert head.count("xmlns=") == 1
|
||
assert 'xmlns="http://www.w3.org/2000/svg"' in head
|
||
|
||
|
||
def test_unclosed_svg_at_end():
|
||
"""`<svg ...><circle cx="50"` (без `</svg>`) → добавляется закрывающий."""
|
||
prefix = '<svg viewBox="0 0 64 64"><circle cx="50"'
|
||
result = parse_to_valid(prefix)
|
||
assert result.endswith("</svg>"), f"нет закрывающего </svg>: {result!r}"
|
||
root = _assert_valid_svg(result)
|
||
assert root.tag.endswith("svg")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 7. Property-based: 100 рандомных префиксов
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_property_based_random_prefixes():
|
||
"""100 случайных префиксов валидного SVG → все парсятся lxml.
|
||
|
||
Берём случайный срез `VALID_SVG` (от 0 символов до всей длины) и для каждого
|
||
вызываем `parse_to_valid`. Результат должен быть валидным XML.
|
||
"""
|
||
rng = random.Random(42) # детерминированный seed для воспроизводимости
|
||
failures: list[tuple[str, str, str]] = []
|
||
|
||
for _ in range(100):
|
||
cut = rng.randint(0, len(VALID_SVG))
|
||
prefix = VALID_SVG[:cut]
|
||
# иногда добавляем "хвост" (мусор после валидной части)
|
||
if rng.random() < 0.3:
|
||
tail = "".join(rng.choices(string.ascii_letters + " \n<>", k=rng.randint(1, 20)))
|
||
prefix = prefix + tail
|
||
# иногда добавляем reasoning перед svg
|
||
if rng.random() < 0.2:
|
||
prefix = "Thinking Process: blah blah\n" + prefix
|
||
try:
|
||
result = parse_to_valid(prefix)
|
||
except Exception as exc: # noqa: BLE001
|
||
failures.append((prefix, f"exception: {exc}", ""))
|
||
continue
|
||
try:
|
||
etree.fromstring(result.encode("utf-8"))
|
||
except etree.XMLSyntaxError as exc:
|
||
failures.append((prefix, str(exc), result))
|
||
|
||
if failures:
|
||
msg = "\n".join(
|
||
f" prefix={p!r}\n err={err}\n out={out[:200]!r}"
|
||
for p, err, out in failures[:5]
|
||
)
|
||
pytest.fail(
|
||
f"{len(failures)} из 100 рандомных префиксов дали невалидный XML:\n{msg}"
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 8. stream_to_valid (опциональный хелпер)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_stream_to_valid_yields_snapshots():
|
||
"""stream_to_valid принимает Iterable стримовых чанков и возвращает Iterator
|
||
валидных SVG-snapshots."""
|
||
chunks = [
|
||
"<svg",
|
||
' viewBox="0 0 64 64"',
|
||
">",
|
||
'<circle cx="32"',
|
||
' cy="32" r="20"',
|
||
"/>",
|
||
"</svg>",
|
||
]
|
||
snapshots = list(stream_to_valid(chunks))
|
||
# хотя бы для каждого чанка должен быть валидный snapshot
|
||
assert len(snapshots) == len(chunks)
|
||
for snap in snapshots:
|
||
# Каждый snapshot — валидный SVG
|
||
root = etree.fromstring(snap.encode("utf-8"))
|
||
assert root.tag.endswith("svg")
|
||
|
||
|
||
def test_stream_to_valid_last_chunk_is_complete():
|
||
"""После последнего полного чанка stream_to_valid должен вернуть полный SVG."""
|
||
chunks = ["<svg", ' viewBox="0 0 64 64"', ">", "</svg>"]
|
||
snapshots = list(stream_to_valid(chunks))
|
||
final = snapshots[-1]
|
||
root = etree.fromstring(final.encode("utf-8"))
|
||
assert root.get("viewBox") == "0 0 64 64"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 9. Невалидные / edge-case входы
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def test_input_is_none_treated_as_empty():
|
||
"""`None` обрабатывается как пустая строка → валидный пустой SVG."""
|
||
# parse_to_valid явно требует str, но проверим, что если кто-то передал None
|
||
# (защитное программирование), мы не падаем.
|
||
try:
|
||
result = parse_to_valid(None) # type: ignore[arg-type]
|
||
except (TypeError, AttributeError):
|
||
# если тип строго str — TypeError допустим
|
||
pytest.skip("parse_to_valid строго требует str (None → TypeError)")
|
||
root = _assert_valid_svg(result)
|
||
assert root.tag.endswith("svg")
|
||
|
||
|
||
def test_ensure_root_false_returns_fragment():
|
||
"""`ensure_root=False` → результат может быть фрагментом (без обёртки в <svg>).
|
||
|
||
Если в prefix уже есть валидный <svg>...</svg>, ensure_root=False
|
||
должен вернуть его (или его фрагмент) без добавления лишнего root.
|
||
"""
|
||
result = parse_to_valid(VALID_SVG, ensure_root=False)
|
||
root = _assert_valid_svg(result)
|
||
# Должен быть валидным
|
||
assert root.tag.endswith("svg")
|
||
|
||
|
||
def test_unicode_in_svg_text():
|
||
"""Unicode в комментариях и тексте — не ломает."""
|
||
prefix = (
|
||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">'
|
||
'<!-- ёжик в тумане -->'
|
||
'<text x="10" y="32">Привет, мир!</text>'
|
||
"</svg>"
|
||
)
|
||
result = parse_to_valid(prefix)
|
||
root = _assert_valid_svg(result)
|
||
assert root.get("viewBox") == "0 0 64 64"
|
||
|
||
|
||
def test_svg_with_doctype_stripped():
|
||
"""Если в prefix есть DOCTYPE-преамбула, она отбрасывается/чистится."""
|
||
prefix = (
|
||
'<?xml version="1.0" encoding="UTF-8"?>'
|
||
'<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">'
|
||
+ VALID_SVG
|
||
)
|
||
result = parse_to_valid(prefix)
|
||
root = _assert_valid_svg(result)
|
||
assert root.get("viewBox") == "0 0 64 64"
|
||
|
||
|
||
def test_path_d_with_numbers_only_no_letter():
|
||
"""`<path d="1234.5 6789` (без `M` и без `>`) — путь не может стартовать с числа.
|
||
Должен либо обрезать, либо как-то починить, главное — XML валидный.
|
||
"""
|
||
prefix = '<svg viewBox="0 0 64 64"><path d="1234.5 6789'
|
||
result = parse_to_valid(prefix)
|
||
root = _assert_valid_svg(result)
|
||
assert root.tag.endswith("svg")
|
||
|
||
|
||
def test_deeply_nested_partial():
|
||
"""Глубокая вложенность частичных тегов: всё закрывается."""
|
||
prefix = (
|
||
'<svg viewBox="0 0 64 64">'
|
||
"<g>"
|
||
"<defs>"
|
||
'<linearGradient id="g1">'
|
||
'<stop offset="0" stop-color="red"'
|
||
# </linearGradient>, </defs>, </g>, </svg> — всё отсутствует
|
||
)
|
||
result = parse_to_valid(prefix)
|
||
root = _assert_valid_svg(result)
|
||
assert root.tag.endswith("svg")
|