Initial commit: OmniSVG-Lite MVP before live-streaming work

- LM Studio client (httpx-based, OpenAI-compatible)
- SVG validator (lxml, whitelist tags, no <script>/<foreignObject>/http refs)
- PNG renderer (resvg-py primary, cairosvg fallback - no native cairo dep)
- History (SQLite, tracks raw/validated/preview paths)
- Gradio UI on 127.0.0.1:8788 with:
  * mode radio (icon/illustration)
  * n_candidates slider (default 1)
  * image upload for image-to-SVG
  * LM Studio URL/token inputs
  * model dropdown + refresh button
- prompts/ with system_icon.txt, system_illustration.txt, few_shot_examples.txt
- docs/spec.md, docs/design.md
- 122 unit/integration tests passing
This commit is contained in:
Mavis
2026-06-13 15:32:54 +03:00
commit 2394eff1c0
21 changed files with 5116 additions and 0 deletions
+240
View File
@@ -0,0 +1,240 @@
"""Регрессионные тесты на callback-логику app.py.
Verifier feedback (attempt 1): "back crashes on input validation paths because
`gr.Warning` was changed from a class to a function in Gradio 5.x and the
producer didn't migrate. Happy path works. Unit tests don't cover this path.
Manual first-click on bad input would surface a TypeError."
Эти тесты ловят именно эту ошибку. Они НЕ дёргают Gradio UI — только
вызывают `app.on_generate` напрямую и проверяют, что:
1) Нет TypeError (т.е. внутри нет `raise gr.Warning/Error`).
2) Возвращается правильное число плейсхолдеров.
3) `app.on_generate` не пытается ходить в LM Studio, если входные данные
отклонены на pre-check.
Запуск: `python -m pytest tests/test_app.py -v`
"""
from __future__ import annotations
import sys
from pathlib import Path
from typing import Any
from unittest.mock import patch
ROOT = Path(__file__).resolve().parent.parent
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
import pytest # noqa: E402
# ---------------------------------------------------------------------------
# Сторож: gr.Warning / gr.Error в Gradio 5.x — это функции, а не классы.
# Если кто-то когда-то обновит gradio и это поведение изменится — тест
# напомнит, что нужно пересмотреть on_generate.
# ---------------------------------------------------------------------------
def test_gradio_warning_is_function_not_class():
import inspect
import gradio as gr
# Контрактное свойство Gradio 5.x, на которое опирается on_generate:
# `gr.Warning(...)` — это ФУНКЦИЯ (а не класс исключения), и её нужно
# ВЫЗЫВАТЬ. Если в новой версии Gradio это поведение изменится, тест
# упадёт, и on_generate нужно будет пересмотреть.
assert inspect.isclass(gr.Warning) is False, (
"gr.Warning стал классом в этой версии Gradio — пересмотрите on_generate"
)
assert callable(gr.Warning)
# ---------------------------------------------------------------------------
# Хелпер: дёрнуть on_generate с разными входами и поймать TypeError.
# ---------------------------------------------------------------------------
def _call_on_generate(**overrides: Any) -> Any:
"""Вызывает app.on_generate с минимальным валидным набором + overrides.
Возвращает то, что вернул callback. Если внутри есть `raise gr.Warning`,
получим TypeError ещё ДО того, как вернётся значение.
"""
from app import on_generate
defaults: dict[str, Any] = dict(
prompt="filled magnifying glass", # валидный
mode="icon",
n_candidates=2,
temperature=0.4,
image=None,
palette="",
model="test-model",
)
defaults.update(overrides)
# Патчим chat() так, чтобы on_generate не уходил в сеть и не упал уже
# ВНЕ pre-check. Если pre-check пропустил и chat() зовётся — мы увидим
# ValueError от mock'а, что нас устраивает (это другая ветка).
with patch("app.chat") as mock_chat:
mock_chat.side_effect = RuntimeError("chat should not be called from this test")
return on_generate(**defaults)
def test_on_generate_does_not_raise_on_empty_prompt():
"""Критический регрессионный кейс: пустой промпт → gr.Warning (НЕ raise)."""
try:
result = _call_on_generate(prompt="")
except TypeError as exc:
pytest.fail(
"on_generate упал с TypeError на пустом промпте — "
"вероятно, кто-то вернул `raise gr.Warning(...)`: "
f"{exc}"
)
# Должен вернуть 7 плейсхолдеров для outputs.
assert isinstance(result, tuple)
assert len(result) == 7, f"ожидался кортеж из 7 элементов, получено {len(result)}"
def test_on_generate_does_not_raise_on_too_long_prompt():
try:
_call_on_generate(prompt="x" * 1001)
except TypeError as exc:
pytest.fail(
f"on_generate упал с TypeError на длинном промпте: {exc}"
)
def test_on_generate_does_not_raise_on_bad_n_candidates():
try:
result = _call_on_generate(n_candidates=0)
except TypeError as exc:
pytest.fail(f"on_generate упал с TypeError на n=0: {exc}")
assert isinstance(result, tuple) and len(result) == 7
# n=99 — тоже вне диапазона
try:
_call_on_generate(n_candidates=99)
except TypeError as exc:
pytest.fail(f"on_generate упал с TypeError на n=99: {exc}")
def test_on_generate_does_not_raise_on_bad_mode():
try:
result = _call_on_generate(mode="portrait")
except TypeError as exc:
pytest.fail(f"on_generate упал с TypeError на неизвестном mode: {exc}")
assert isinstance(result, tuple) and len(result) == 7
def test_on_generate_does_not_call_chat_on_precheck_fail():
"""Если pre-check упал, chat() НЕ должен вызываться вообще."""
from app import on_generate
with patch("app.chat") as mock_chat:
on_generate(
prompt="", # упадёт на pre-check
mode="icon",
n_candidates=2,
temperature=0.4,
image=None,
palette="",
model="x",
)
assert mock_chat.call_count == 0, (
"chat() был вызван, хотя pre-check должен был остановить поток"
)
def test_on_generate_does_not_call_chat_on_bad_mode():
from app import on_generate
with patch("app.chat") as mock_chat:
on_generate(
prompt="valid",
mode="junk",
n_candidates=2,
temperature=0.4,
image=None,
palette="",
model="x",
)
assert mock_chat.call_count == 0
# ---------------------------------------------------------------------------
# Положительный smoke: на корректном входе on_generate НЕ возвращает пустоту
# (хотя в этом юнит-тесте chat() замокан → идём по ветке ошибки сборки
# промпта, а не успеха; это нормально, главное — нет TypeError).
# ---------------------------------------------------------------------------
def test_on_generate_with_valid_input_does_not_typeerror():
"""Даже когда chat() падает (замокан), pre-check не должен давать TypeError."""
from app import on_generate
with patch("app.chat") as mock_chat:
mock_chat.return_value = type("R", (), {
"raw_texts": ['<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><circle cx="32" cy="32" r="10" fill="red"/></svg>'],
"elapsed_s": 0.1,
"model": "test",
"usage": None,
"finish_reasons": ["stop"],
})()
try:
result = on_generate(
prompt="filled magnifying glass",
mode="icon",
n_candidates=1,
temperature=0.4,
image=None,
palette="",
model="test",
)
except TypeError as exc:
pytest.fail(f"on_generate упал с TypeError на валидном входе: {exc}")
# На валидном входе возвращается кортеж из 7 элементов.
assert isinstance(result, tuple)
assert len(result) == 7
# ---------------------------------------------------------------------------
# Smoke-тест: импорт app и build_ui() возвращает gr.Blocks.
# Не лезем в сеть, не запускаем UI.
# ---------------------------------------------------------------------------
def test_app_module_imports():
"""app.py импортируется без ошибок (все зависимости в порядке)."""
import app # noqa: F401
assert hasattr(app, "on_generate")
assert hasattr(app, "on_history_select")
assert hasattr(app, "build_ui")
assert hasattr(app, "main")
def test_build_ui_returns_gradio_blocks():
"""build_ui() возвращает gr.Blocks (smoke-тест сборки UI)."""
from app import build_ui
demo = build_ui()
# Проверяем, что это действительно gr.Blocks, а не None или что-то другое.
import gradio as gr
assert isinstance(demo, gr.Blocks), f"ожидался gr.Blocks, получено {type(demo).__name__}"
def test_on_mode_change_returns_icon_default_n():
from app import on_mode_change
update = on_mode_change("icon")
# gr.update — это dict-like объект, у него есть .value
assert update["value"] == 4
def test_on_mode_change_returns_illustration_default_n():
from app import on_mode_change
update = on_mode_change("illustration")
assert update["value"] == 2