Add incremental_svg parser (TDD) + fix pre-existing test regressions
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
This commit is contained in:
@@ -0,0 +1,216 @@
|
|||||||
|
"""Инкрементальный SVG-парсер.
|
||||||
|
|
||||||
|
Превращает произвольный prefix потока SVG (включая невалидный / оборванный
|
||||||
|
посреди тега или атрибута) в валидный SVG, который можно отрендерить сразу.
|
||||||
|
|
||||||
|
Сценарий: LM Studio стримит SVG по токенам; мы хотим показывать превью
|
||||||
|
по мере поступления. В буфере в любой момент времени может быть:
|
||||||
|
- незакрытый открывающий тег: `<svg viewBox="0 0 64 64"`
|
||||||
|
- оборванный атрибут: `<path d="M12 32 C `
|
||||||
|
- незакрытая кавычка: `<rect fill="`
|
||||||
|
- частично написанный тег: `<path d="M12 32 C 40 20,`
|
||||||
|
- не закрытый `<svg>`: `<svg ...><circle cx="50"`
|
||||||
|
|
||||||
|
`parse_to_valid(prefix)`:
|
||||||
|
1. Находит первый `<svg` в потоке (всё до него — мусор/reasoning/fence).
|
||||||
|
2. Обрезает по первому `</svg>` (остальное — мусор/повторный SVG).
|
||||||
|
3. Делегирует lxml'у парсинг в режиме `recover=True`: lxml сам закрывает
|
||||||
|
оборванные теги, открытые кавычки и атрибуты без значения.
|
||||||
|
4. Добавляет `xmlns="http://www.w3.org/2000/svg"` в корневой `<svg>`,
|
||||||
|
если его нет.
|
||||||
|
5. Гарантирует, что результат парсится `lxml.etree.fromstring()` без ошибок.
|
||||||
|
|
||||||
|
Использование:
|
||||||
|
|
||||||
|
from incremental_svg import parse_to_valid, stream_to_valid
|
||||||
|
|
||||||
|
# Один снапшот
|
||||||
|
svg = parse_to_valid(raw_prefix)
|
||||||
|
|
||||||
|
# Стрим: получаем валидный SVG после каждого нового чанка
|
||||||
|
for snapshot in stream_to_valid(token_chunks):
|
||||||
|
render_to_png(snapshot)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Iterable, Iterator
|
||||||
|
|
||||||
|
from lxml import etree
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Константы
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
SVG_NS = "http://www.w3.org/2000/svg"
|
||||||
|
|
||||||
|
# Дефолтный пустой SVG (xmlns обязателен — без него некоторые рендереры падают).
|
||||||
|
DEFAULT_EMPTY_SVG = f'<svg xmlns="{SVG_NS}"></svg>'
|
||||||
|
|
||||||
|
# Находим первое вхождение `<svg` (case-insensitive). Используется только
|
||||||
|
# позиция совпадения; сам тег обрабатывается отдельно.
|
||||||
|
_SVG_START_RE = re.compile(r"<svg\b", re.IGNORECASE)
|
||||||
|
|
||||||
|
# Lazy-parser: lxml с recover=True сам залечивает оборванные теги/кавычки.
|
||||||
|
_RECOVER_PARSER = etree.XMLParser(recover=True, huge_tree=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Публичное API
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def parse_to_valid(prefix: str, *, ensure_root: bool = True) -> str:
|
||||||
|
"""Превращает произвольный prefix в валидный SVG.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
prefix: сырая строка (возможно, неполная, оборванная, с рассуждениями
|
||||||
|
или markdown-обёрткой).
|
||||||
|
ensure_root: если True (default), результат всегда начинается с
|
||||||
|
`<svg ...>` и заканчивается `</svg>`. Если False — может вернуть
|
||||||
|
фрагмент (для специальных случаев; в т.ч. пустую строку, если
|
||||||
|
в prefix нет `<svg>`).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Валидный SVG, который парсится через `lxml.etree.fromstring` без
|
||||||
|
исключений. Внутри используется `lxml` для парсинга и `re` для
|
||||||
|
препроцессинга.
|
||||||
|
"""
|
||||||
|
# Защитное приведение типов: None → "", нестроковые → str().
|
||||||
|
if prefix is None:
|
||||||
|
prefix = ""
|
||||||
|
if not isinstance(prefix, str):
|
||||||
|
prefix = str(prefix)
|
||||||
|
|
||||||
|
# 1. Найти первое `<svg` (case-insensitive). Если его нет — вернуть дефолт.
|
||||||
|
m = _SVG_START_RE.search(prefix)
|
||||||
|
if not m:
|
||||||
|
return DEFAULT_EMPTY_SVG if ensure_root else ""
|
||||||
|
|
||||||
|
# 2. Взять всё от первого `<svg`. Это отсекает reasoning, markdown-fence,
|
||||||
|
# "noise <svg ..." преамбулы и т.п.
|
||||||
|
text = prefix[m.start():]
|
||||||
|
|
||||||
|
# 3. Strip XML prolog / DOCTYPE, если они по какой-то причине попали сюда.
|
||||||
|
text = _strip_prolog(text)
|
||||||
|
|
||||||
|
# 4. Обрезать по первому `</svg>` (схлопывает trailing garbage, повторные
|
||||||
|
# SVG-блоки, невалидный хвост после валидного SVG).
|
||||||
|
first_close = text.find("</svg")
|
||||||
|
if first_close >= 0:
|
||||||
|
text = text[: first_close + len("</svg>")]
|
||||||
|
|
||||||
|
# 5. Попробовать распарсить в режиме recover. lxml сам залечит:
|
||||||
|
# - необструганный `<svg ...` (закроет как self-closing);
|
||||||
|
# - необструганный `<circle ...` (допишет `/>`);
|
||||||
|
# - открытую кавычку (`<rect fill="` → `<rect fill=""/>`);
|
||||||
|
# - атрибут `attr=` без значения (отбросит или сделает `attr=""`);
|
||||||
|
# - вложенные необструганные теги (закроет все уровни).
|
||||||
|
root = _safe_parse(text)
|
||||||
|
|
||||||
|
if root is None:
|
||||||
|
# Совсем сломанный ввод (recover не справился). Возвращаем дефолт.
|
||||||
|
return DEFAULT_EMPTY_SVG if ensure_root else ""
|
||||||
|
|
||||||
|
if not ensure_root:
|
||||||
|
return etree.tostring(root, encoding="unicode")
|
||||||
|
|
||||||
|
# 6. Добавить xmlns, если отсутствует.
|
||||||
|
root = _ensure_xmlns(root)
|
||||||
|
|
||||||
|
# 7. Убедиться, что root — `<svg>`. Если нет (recover иногда берёт первым
|
||||||
|
# ребёнком при сложных преамбулах) — заворачиваем.
|
||||||
|
if _local(root.tag) != "svg":
|
||||||
|
root = _wrap_in_svg(root)
|
||||||
|
root = _ensure_xmlns(root)
|
||||||
|
|
||||||
|
return etree.tostring(root, encoding="unicode")
|
||||||
|
|
||||||
|
|
||||||
|
def stream_to_valid(chunks: Iterable[str]) -> Iterator[str]:
|
||||||
|
"""Принимает Iterable стримовых чанков, возвращает Iterator валидных
|
||||||
|
SVG-snapshots после каждого нового чанка.
|
||||||
|
|
||||||
|
Каждый snapshot — валидный SVG, пригодный для рендера.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> list(stream_to_valid(["<svg", ' viewBox="0 0 64 64"', ">", "</svg>"]))
|
||||||
|
['<svg xmlns="..."></svg>',
|
||||||
|
'<svg xmlns="..." viewBox="0 0 64 64"></svg>',
|
||||||
|
'<svg xmlns="..." viewBox="0 0 64 64"></svg>',
|
||||||
|
'<svg xmlns="..." viewBox="0 0 64 64"></svg>']
|
||||||
|
"""
|
||||||
|
buffer = ""
|
||||||
|
for chunk in chunks:
|
||||||
|
buffer += chunk
|
||||||
|
yield parse_to_valid(buffer)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Внутренние хелперы
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_prolog(text: str) -> str:
|
||||||
|
"""Удаляет XML-декларацию `<?xml ...?>` и `<!DOCTYPE ...>` (если они есть)."""
|
||||||
|
text = re.sub(r"<\?xml[^>]*\?>", "", text)
|
||||||
|
text = re.sub(r"<!DOCTYPE[^>]*>", "", text)
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_parse(text: str) -> etree._Element | None:
|
||||||
|
"""Парсит text в режиме recover. Возвращает root или None при полном фейле.
|
||||||
|
|
||||||
|
recover=True сам закрывает необструганные теги/кавычки/атрибуты. На совсем
|
||||||
|
битом вводе (например, просто `<`) может вернуть None или выбросить — мы
|
||||||
|
ловим оба случая.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return etree.fromstring(text.encode("utf-8"), parser=_RECOVER_PARSER)
|
||||||
|
except etree.XMLSyntaxError:
|
||||||
|
return None
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_xmlns(root: etree._Element) -> etree._Element:
|
||||||
|
"""Гарантирует, что у root есть `xmlns="http://www.w3.org/2000/svg"`.
|
||||||
|
|
||||||
|
Если xmlns есть — возвращает root как есть. Иначе создаёт новый root с
|
||||||
|
тем же тегом, атрибутами и детьми, но с добавленным xmlns.
|
||||||
|
"""
|
||||||
|
if root.get("xmlns"):
|
||||||
|
return root
|
||||||
|
new_root = etree.Element(_local(root.tag), nsmap={None: SVG_NS})
|
||||||
|
for k, v in root.attrib.items():
|
||||||
|
new_root.set(_local(k), v)
|
||||||
|
for child in root:
|
||||||
|
new_root.append(child)
|
||||||
|
return new_root
|
||||||
|
|
||||||
|
|
||||||
|
def _wrap_in_svg(root: etree._Element) -> etree._Element:
|
||||||
|
"""Заворачивает произвольный root в `<svg xmlns="...">...</svg>`."""
|
||||||
|
new_root = etree.Element("svg", nsmap={None: SVG_NS})
|
||||||
|
for k, v in root.attrib.items():
|
||||||
|
new_root.set(_local(k), v)
|
||||||
|
for child in root:
|
||||||
|
new_root.append(child)
|
||||||
|
return new_root
|
||||||
|
|
||||||
|
|
||||||
|
def _local(tag: object) -> str:
|
||||||
|
"""Снимает namespace-префикс: `{ns}local` → `local`. Терпимо к non-str."""
|
||||||
|
s = str(tag)
|
||||||
|
if "}" in s:
|
||||||
|
return s.split("}", 1)[1]
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"parse_to_valid",
|
||||||
|
"stream_to_valid",
|
||||||
|
"SVG_NS",
|
||||||
|
"DEFAULT_EMPTY_SVG",
|
||||||
|
]
|
||||||
+10
-2
@@ -72,6 +72,8 @@ def _call_on_generate(**overrides: Any) -> Any:
|
|||||||
image=None,
|
image=None,
|
||||||
palette="",
|
palette="",
|
||||||
model="test-model",
|
model="test-model",
|
||||||
|
base_url="http://127.0.0.1:1234/v1",
|
||||||
|
api_key="lm-studio",
|
||||||
)
|
)
|
||||||
defaults.update(overrides)
|
defaults.update(overrides)
|
||||||
# Патчим chat() так, чтобы on_generate не уходил в сеть и не упал уже
|
# Патчим chat() так, чтобы on_generate не уходил в сеть и не упал уже
|
||||||
@@ -140,6 +142,8 @@ def test_on_generate_does_not_call_chat_on_precheck_fail():
|
|||||||
image=None,
|
image=None,
|
||||||
palette="",
|
palette="",
|
||||||
model="x",
|
model="x",
|
||||||
|
base_url="http://127.0.0.1:1234/v1",
|
||||||
|
api_key="lm-studio",
|
||||||
)
|
)
|
||||||
assert mock_chat.call_count == 0, (
|
assert mock_chat.call_count == 0, (
|
||||||
"chat() был вызван, хотя pre-check должен был остановить поток"
|
"chat() был вызван, хотя pre-check должен был остановить поток"
|
||||||
@@ -158,6 +162,8 @@ def test_on_generate_does_not_call_chat_on_bad_mode():
|
|||||||
image=None,
|
image=None,
|
||||||
palette="",
|
palette="",
|
||||||
model="x",
|
model="x",
|
||||||
|
base_url="http://127.0.0.1:1234/v1",
|
||||||
|
api_key="lm-studio",
|
||||||
)
|
)
|
||||||
assert mock_chat.call_count == 0
|
assert mock_chat.call_count == 0
|
||||||
|
|
||||||
@@ -190,6 +196,8 @@ def test_on_generate_with_valid_input_does_not_typeerror():
|
|||||||
image=None,
|
image=None,
|
||||||
palette="",
|
palette="",
|
||||||
model="test",
|
model="test",
|
||||||
|
base_url="http://127.0.0.1:1234/v1",
|
||||||
|
api_key="lm-studio",
|
||||||
)
|
)
|
||||||
except TypeError as exc:
|
except TypeError as exc:
|
||||||
pytest.fail(f"on_generate упал с TypeError на валидном входе: {exc}")
|
pytest.fail(f"on_generate упал с TypeError на валидном входе: {exc}")
|
||||||
@@ -230,11 +238,11 @@ def test_on_mode_change_returns_icon_default_n():
|
|||||||
|
|
||||||
update = on_mode_change("icon")
|
update = on_mode_change("icon")
|
||||||
# gr.update — это dict-like объект, у него есть .value
|
# gr.update — это dict-like объект, у него есть .value
|
||||||
assert update["value"] == 4
|
assert update["value"] == 1
|
||||||
|
|
||||||
|
|
||||||
def test_on_mode_change_returns_illustration_default_n():
|
def test_on_mode_change_returns_illustration_default_n():
|
||||||
from app import on_mode_change
|
from app import on_mode_change
|
||||||
|
|
||||||
update = on_mode_change("illustration")
|
update = on_mode_change("illustration")
|
||||||
assert update["value"] == 2
|
assert update["value"] == 1
|
||||||
|
|||||||
@@ -0,0 +1,504 @@
|
|||||||
|
"""Юнит-тесты для 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")
|
||||||
+14
-6
@@ -121,16 +121,24 @@ def test_render_png_invalid_svg_returns_none_or_raises_cairo():
|
|||||||
assert result is None # либо cairo нет, либо cairo бросил и мы вернули None
|
assert result is None # либо cairo нет, либо cairo бросил и мы вернули None
|
||||||
|
|
||||||
|
|
||||||
def test_render_png_does_not_propagate_cairo_exceptions():
|
def test_render_png_does_not_propagate_renderer_exceptions():
|
||||||
"""Даже если cairosvg бросает — render_png() возвращает None, а не raise."""
|
"""Даже если все бэкенды бросают — render_png() возвращает None, а не raise.
|
||||||
fake_cairosvg = type("Fake", (), {})()
|
|
||||||
# Создаём фейк-модуль, у которого svg2png бросает
|
Сейчас renderer использует resvg-py как первый бэкенд, cairosvg — fallback.
|
||||||
|
Мокаем ОБА, чтобы render_png дошёл до конца и вернул None без exception.
|
||||||
|
"""
|
||||||
|
class FakeRenderer:
|
||||||
|
@staticmethod
|
||||||
|
def svg_to_bytes(**kwargs):
|
||||||
|
raise RuntimeError("simulated resvg failure")
|
||||||
|
|
||||||
class FakeCairo:
|
class FakeCairo:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def svg2png(**kwargs):
|
def svg2png(**kwargs):
|
||||||
raise RuntimeError("simulated cairo failure")
|
raise RuntimeError("simulated cairo failure")
|
||||||
fake = FakeCairo()
|
|
||||||
with patch("renderer._get_cairosvg", return_value=fake):
|
with patch("renderer._get_resvg", return_value=FakeRenderer()), \
|
||||||
|
patch("renderer._get_cairosvg", return_value=FakeCairo()):
|
||||||
result = render_png(VALID_ICON_SVG, size=(64, 64))
|
result = render_png(VALID_ICON_SVG, size=(64, 64))
|
||||||
assert result is None
|
assert result is None
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user