Files
omnisvg-lite/tests/test_incremental_svg.py
Mavis 17a114e015 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
2026-06-13 15:54:11 +03:00

505 lines
20 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Юнит-тесты для 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")