"""Юнит-тесты для 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 со вложенными открытыми тегами (для теста вложенности) NESTED_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 либо в , либо подразумевается через сериализацию.""" 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 (с корневым ).""" 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 с ... внутри → возвращается как есть.""" 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.""" result = parse_to_valid('` → валидный SVG.""" result = parse_to_valid("`) → валидный SVG.""" result = parse_to_valid('`) → закрывает и атрибут, и тег.""" prefix = '` → не закрывается повторно.""" prefix = '' 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(): """Вложенные `` (если открыт) → оба закрыты.""" prefix = ', внутри — 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 = '` подряд — берём только первый валидный.""" second = ( '' '' "" ) 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(): """Если в `` нет xmlns, добавляется.""" prefix = '' result = parse_to_valid(prefix) # xmlns должен появиться в head 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(): """``) → добавляется закрывающий.""" prefix = '"), f"нет закрывающего : {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 = [ "", '", "", ] 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 = ["", ""] 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` → результат может быть фрагментом (без обёртки в ). Если в prefix уже есть валидный ..., 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 = ( '' '' 'Привет, мир!' "" ) 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 = ( '' '' + 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(): """`' "" "" '' ', , , — всё отсутствует ) result = parse_to_valid(prefix) root = _assert_valid_svg(result) assert root.tag.endswith("svg")