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:
+10
-2
@@ -72,6 +72,8 @@ def _call_on_generate(**overrides: Any) -> Any:
|
||||
image=None,
|
||||
palette="",
|
||||
model="test-model",
|
||||
base_url="http://127.0.0.1:1234/v1",
|
||||
api_key="lm-studio",
|
||||
)
|
||||
defaults.update(overrides)
|
||||
# Патчим chat() так, чтобы on_generate не уходил в сеть и не упал уже
|
||||
@@ -140,6 +142,8 @@ def test_on_generate_does_not_call_chat_on_precheck_fail():
|
||||
image=None,
|
||||
palette="",
|
||||
model="x",
|
||||
base_url="http://127.0.0.1:1234/v1",
|
||||
api_key="lm-studio",
|
||||
)
|
||||
assert mock_chat.call_count == 0, (
|
||||
"chat() был вызван, хотя pre-check должен был остановить поток"
|
||||
@@ -158,6 +162,8 @@ def test_on_generate_does_not_call_chat_on_bad_mode():
|
||||
image=None,
|
||||
palette="",
|
||||
model="x",
|
||||
base_url="http://127.0.0.1:1234/v1",
|
||||
api_key="lm-studio",
|
||||
)
|
||||
assert mock_chat.call_count == 0
|
||||
|
||||
@@ -190,6 +196,8 @@ def test_on_generate_with_valid_input_does_not_typeerror():
|
||||
image=None,
|
||||
palette="",
|
||||
model="test",
|
||||
base_url="http://127.0.0.1:1234/v1",
|
||||
api_key="lm-studio",
|
||||
)
|
||||
except TypeError as 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")
|
||||
# gr.update — это dict-like объект, у него есть .value
|
||||
assert update["value"] == 4
|
||||
assert update["value"] == 1
|
||||
|
||||
|
||||
def test_on_mode_change_returns_illustration_default_n():
|
||||
from app import on_mode_change
|
||||
|
||||
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
|
||||
|
||||
|
||||
def test_render_png_does_not_propagate_cairo_exceptions():
|
||||
"""Даже если cairosvg бросает — render_png() возвращает None, а не raise."""
|
||||
fake_cairosvg = type("Fake", (), {})()
|
||||
# Создаём фейк-модуль, у которого svg2png бросает
|
||||
def test_render_png_does_not_propagate_renderer_exceptions():
|
||||
"""Даже если все бэкенды бросают — render_png() возвращает None, а не raise.
|
||||
|
||||
Сейчас renderer использует resvg-py как первый бэкенд, cairosvg — fallback.
|
||||
Мокаем ОБА, чтобы render_png дошёл до конца и вернул None без exception.
|
||||
"""
|
||||
class FakeRenderer:
|
||||
@staticmethod
|
||||
def svg_to_bytes(**kwargs):
|
||||
raise RuntimeError("simulated resvg failure")
|
||||
|
||||
class FakeCairo:
|
||||
@staticmethod
|
||||
def svg2png(**kwargs):
|
||||
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))
|
||||
assert result is None
|
||||
|
||||
|
||||
Reference in New Issue
Block a user