17a114e015
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
249 lines
9.9 KiB
Python
249 lines
9.9 KiB
Python
"""Регрессионные тесты на 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",
|
||
base_url="http://127.0.0.1:1234/v1",
|
||
api_key="lm-studio",
|
||
)
|
||
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",
|
||
base_url="http://127.0.0.1:1234/v1",
|
||
api_key="lm-studio",
|
||
)
|
||
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",
|
||
base_url="http://127.0.0.1:1234/v1",
|
||
api_key="lm-studio",
|
||
)
|
||
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",
|
||
base_url="http://127.0.0.1:1234/v1",
|
||
api_key="lm-studio",
|
||
)
|
||
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"] == 1
|
||
|
||
|
||
def test_on_mode_change_returns_illustration_default_n():
|
||
from app import on_mode_change
|
||
|
||
update = on_mode_change("illustration")
|
||
assert update["value"] == 1
|