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:
Mavis
2026-06-13 15:54:11 +03:00
parent 2394eff1c0
commit 17a114e015
4 changed files with 744 additions and 8 deletions
+216
View File
@@ -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
View File
@@ -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
+504
View File
@@ -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
View File
@@ -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