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:
@@ -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
|
||||
Reference in New Issue
Block a user