Files
Mavis 36d9906e1f Finalize live-streaming feature: docs and tests
- docs/live_streaming.md: feature description, perf, limitations
- 183 tests passing (was 157; added 26+ for streaming + live UI)
- All previous regressions fixed

Owner-action: completed final-integration myself after tester session
got stuck on the e2e attempt (likely trying to spawn a real Gradio on
an already-busy port). Manual verification: 183 passed, 1 skipped,
0 failed; feature works end-to-end via Gradio UI on 127.0.0.1:8788.
2026-06-13 16:40:19 +03:00

606 lines
26 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.
"""Регрессионные тесты на 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
import time
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
# ---------------------------------------------------------------------------
# Live-режим (on_generate_live)
#
# Пять обязательных тестов из ТЗ live-ui:
# 1. yields ≥ 2 промежуточных превью при 5 дельтах
# 2. финальный yield содержит status "Сгенерировано" и валидный SVG
# 3. throttle режет быстрые дельты (10 за 50мс → ≤ 4-5 yield'ов)
# 4. после end event в SQLite появляется запись (status=ok/partial)
# 5. при LMStudioUnavailable вызывается gr.Error
#
# Плюс sanity-тест: on_generate_live — генератор-функция.
# ---------------------------------------------------------------------------
# Реалистичный поток дельт, который последовательно строит валидный SVG.
# Используется в тестах 1, 2, 4 — здесь важно, чтобы parse_to_valid давал
# рендерабельный SVG на большинстве промежуточных стадий, а не только в конце.
_VALID_SVG_DELTAS = [
"<svg ",
'xmlns="http://www.w3.org/2000/svg" ',
'viewBox="0 0 64 64">',
'<rect x="10" y="10" width="44" height="44" fill="red"/>',
"</svg>",
]
def _make_stream_mock(deltas, *, sleep_s: float = 0.20):
"""Возвращает мок-функцию stream_chat, отдающую дельты + 'end'.
Args:
deltas: список строк-дельта.
sleep_s: пауза между дельтами (default 200мс) — чтобы throttle
не скипнул промежуточные yield'ы в "нормальном" сценарии.
"""
from lm_client import StreamEvent
def mock_stream_chat(*args, **kwargs):
for d in deltas:
if sleep_s > 0:
time.sleep(sleep_s)
yield StreamEvent(type="delta", content=d)
yield StreamEvent(type="end", model="test-model", finish_reason="stop")
return mock_stream_chat
def _call_live(**overrides):
"""Дёргает on_generate_live с разумными дефолтами + overrides.
Возвращает список yields (т.е. материализованный генератор).
"""
from app import on_generate_live
defaults: dict[str, Any] = dict(
prompt="filled magnifying glass",
mode="icon",
n_candidates=1,
temperature=0.4,
image=None,
palette="",
model="test-model",
base_url="http://127.0.0.1:1234/v1",
api_key="lm-studio",
use_live=True,
)
defaults.update(overrides)
gen = on_generate_live(**defaults)
return list(gen)
def _preview_yields(yields):
"""Возвращает только те yield'ы, в которых gallery содержит превью."""
out = []
for y in yields:
# y = (gallery, gallery_state, svg, history_df, status, paths, error)
gallery = y[0]
if gallery and isinstance(gallery, list) and len(gallery) > 0:
first = gallery[0]
if isinstance(first, tuple) and len(first) >= 1 and first[0]:
out.append(y)
return out
# --- Sanity: on_generate_live — генератор-функция ---------------------------
def test_on_generate_live_is_generator_function():
"""on_generate_live должна быть генератор-функцией (содержит yield)."""
import inspect
from app import on_generate_live
assert inspect.isgeneratorfunction(on_generate_live), (
"on_generate_live должна быть генератор-функцией "
"(содержать yield) для Gradio streaming pattern"
)
# --- Тест 1: ≥ 2 промежуточных yield'а при 5 дельтах ------------------------
def test_live_mode_yields_intermediate_previews(tmp_path, monkeypatch):
"""Мок stream_chat отдаёт 5 дельт; on_generate_live делает ≥ 2 yield'а
с обновлениями Gallery (промежуточные превью)."""
monkeypatch.setenv("OMNISVG_DB_PATH", str(tmp_path / "history.sqlite"))
monkeypatch.setenv("OMNISVG_PREVIEW_DIR", str(tmp_path / "previews"))
mock = _make_stream_mock(_VALID_SVG_DELTAS, sleep_s=0.20)
with patch("app.stream_chat", side_effect=mock):
yields = _call_live()
previews = _preview_yields(yields)
assert len(previews) >= 2, (
f"ожидалось ≥ 2 промежуточных preview-yield'а, получено {len(previews)}; "
f"всего yields={len(yields)}"
)
# Sanity: у промежуточных yield'ов в gallery действительно лежит файл
# (path), не просто заглушка.
for y in previews[:-1]: # все, кроме последнего (финального)
gallery = y[0]
path_str = gallery[0][0]
assert path_str.endswith(".png"), f"ожидался .png путь, получено {path_str!r}"
# --- Тест 2: финальный yield содержит "Сгенерировано" и валидный SVG --------
def test_live_mode_final_yield_after_end(tmp_path, monkeypatch):
"""Последний yield содержит status "Сгенерировано" и валидный SVG."""
monkeypatch.setenv("OMNISVG_DB_PATH", str(tmp_path / "history.sqlite"))
monkeypatch.setenv("OMNISVG_PREVIEW_DIR", str(tmp_path / "previews"))
mock = _make_stream_mock(_VALID_SVG_DELTAS, sleep_s=0.20)
with patch("app.stream_chat", side_effect=mock):
yields = _call_live()
assert len(yields) >= 1
final = yields[-1]
# (gallery, gallery_state, svg, history_df, status_md, paths, error)
status_md = final[4]
final_svg = final[2]
assert "Сгенерировано" in status_md, (
f"ожидалось 'Сгенерировано' в status_md, получено {status_md!r}"
)
# Валидный SVG: парсится lxml'ом, начинается с <svg, содержит </svg>.
assert final_svg, "финальный SVG не должен быть пустым"
assert "<svg" in final_svg
assert "</svg>" in final_svg
from lxml import etree
etree.fromstring(final_svg.encode("utf-8")) # должно парситься без ошибок
# --- Тест 3: throttle режет быстрые дельты --------------------------------
def test_live_mode_throttle_skips_rapid_updates(tmp_path, monkeypatch):
"""10 дельт подряд → preview-yield'ов строго меньше, чем 10.
Чтобы изолировать throttle от скорости рендера (resvg-py + диск),
мокаем render_png на мгновенный возврат фиктивных PNG-байт.
Без throttle мы получили бы 10 preview-yield'ов; с throttle=0.15s
на быстром стриме (10 дельт за <50мс) — максимум 1-2 preview-yield'а.
"""
monkeypatch.setenv("OMNISVG_DB_PATH", str(tmp_path / "history.sqlite"))
monkeypatch.setenv("OMNISVG_PREVIEW_DIR", str(tmp_path / "previews"))
# Мок render_png: возвращает фейковые PNG-байты мгновенно, чтобы
# тест измерял ТОЛЬКО throttle, а не скорость resvg/диска.
fake_png = b"\x89PNG\r\n\x1a\n" + b"\x00" * 32 # фейк-PNG заголовок
with patch("app.render_png", return_value=fake_png):
fast_deltas = [
"<svg ", # deltas 1
'xmlns="http://www.w3.org/2000/svg" ',
'viewBox="0 0 64 64">',
'<rect x="10" y="10" width="44" height="44" fill="red"/>',
'<circle cx="32" cy="32" r="5" fill="blue"/>',
'<line x1="0" y1="0" x2="64" y2="64" stroke="green"/>',
'<text x="32" y="32">A</text>',
'<ellipse cx="20" cy="20" rx="5" ry="3" fill="purple"/>',
'<polygon points="50,10 60,30 40,30" fill="orange"/>', # deltas 10
"</svg>",
]
mock = _make_stream_mock(fast_deltas, sleep_s=0.0)
started = time.monotonic()
with patch("app.stream_chat", side_effect=mock):
yields = _call_live()
elapsed_ms = (time.monotonic() - started) * 1000
previews = _preview_yields(yields)
# 10 дельт без throttle = 10 preview-yield'ов (плюс 1 начальный "поехали"
# без gallery). С throttle=0.15s и мгновенным render_png: ≤ 1-2 yields
# (первая дельта даёт, остальные скипнуты т.к. < 150мс).
# Ставим жёсткий потолок 5, чтобы тест не флакал.
assert len(previews) <= 5, (
f"throttle не сработал: {len(previews)} preview-yield'ов за {elapsed_ms:.0f}мс; "
f"предел 5"
)
# Sanity: если throttle был ВООБЩЕ выключен, было бы ~10. Проверяем,
# что превью-yield'ов сильно меньше количества дельт (= 10).
assert len(previews) < len(fast_deltas), (
f"throttle не режет: {len(previews)} превью на {len(fast_deltas)} дельт"
)
# --- Тест 4: после end event в SQLite появилась запись (ok/partial) ---------
def test_live_mode_records_in_history(tmp_path, monkeypatch):
"""После end event в SQLite появилась запись со status=ok/partial."""
monkeypatch.setenv("OMNISVG_DB_PATH", str(tmp_path / "history.sqlite"))
monkeypatch.setenv("OMNISVG_PREVIEW_DIR", str(tmp_path / "previews"))
mock = _make_stream_mock(_VALID_SVG_DELTAS, sleep_s=0.20)
with patch("app.stream_chat", side_effect=mock):
yields = _call_live()
# Генерируем запись. Проверяем, что в SQLite есть новая строка.
from history import History
with History() as h:
records = h.list_recent(limit=5)
assert len(records) >= 1, "в History() нет ни одной записи после live-стрима"
last = records[0] # list_recent сортирует DESC, новая запись — первая
assert last["status"] in ("ok", "partial"), (
f"ожидался status ok/partial, получено {last['status']!r}"
)
assert last["prompt"] == "filled magnifying glass"
assert last["mode"] == "icon"
assert last["n_requested"] == 1
# raw_outputs должен содержать полный склеенный текст стрима.
assert last["raw_outputs"], "raw_outputs пуст"
assert "".join(last["raw_outputs"]).startswith("<svg ")
# --- Тест 5: LMStudioUnavailable → gr.Error ---------------------------------
def test_live_mode_handles_stream_error(tmp_path, monkeypatch):
"""Если stream_chat бросает LMStudioUnavailable, callback вызывает gr.Error
и возвращает пустой результат."""
monkeypatch.setenv("OMNISVG_DB_PATH", str(tmp_path / "history.sqlite"))
monkeypatch.setenv("OMNISVG_PREVIEW_DIR", str(tmp_path / "previews"))
from lm_client import LMStudioUnavailable
def boom(*args, **kwargs):
raise LMStudioUnavailable("test: connection refused")
yield # generator-never-yield, помечает boom как генератор
with patch("app.stream_chat", side_effect=boom), \
patch("app.gr.Error") as mock_error:
yields = _call_live()
# Должен быть вызван gr.Error (без raise).
assert mock_error.called, "gr.Error не был вызван при LMStudioUnavailable"
# Должен быть ≥ 1 yield. Реально 2: первый — "Live-стрим запущен…",
# второй — _empty_result после except. Главное — последний yield
# содержит пустые плейсхолдеры (7 элементов).
assert len(yields) >= 1
last = yields[-1]
assert isinstance(last, tuple) and len(last) == 7
# Финальный gallery / status пустые.
assert last[0] == [] or last[0] is None or last[0] == ()
# И в SQLite записался failed-кейс (для аудита попыток).
from history import History
with History() as h:
records = h.list_recent(limit=5)
assert len(records) >= 1
failed = records[0]
assert failed["status"] == "failed"
assert "connection refused" in (failed["error_reason"] or "")
# --- Бонус: use_live=False — fallback на синхронный on_generate ------------
def test_live_mode_false_falls_back_to_sync(tmp_path, monkeypatch):
"""Если use_live=False, генератор делает один yield с результатом on_generate."""
monkeypatch.setenv("OMNISVG_DB_PATH", str(tmp_path / "history.sqlite"))
monkeypatch.setenv("OMNISVG_PREVIEW_DIR", str(tmp_path / "previews"))
# Стрим вообще не должен вызываться.
with patch("app.stream_chat") as mock_stream, \
patch("app.chat") as mock_chat:
# chat() возвращает фиктивный результат с 1 валидным SVG.
from dataclasses import dataclass
@dataclass
class FakeResult:
raw_texts: list[str]
elapsed_s: float = 0.1
model: str = "test"
usage: dict | None = None
finish_reasons: list[str] = None
def _fr():
return ["stop"]
mock_chat.return_value = FakeResult(
raw_texts=[
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">'
'<rect x="10" y="10" width="44" height="44" fill="red"/></svg>'
],
finish_reasons=_fr(),
)
yields = _call_live(use_live=False)
assert mock_stream.call_count == 0, (
"stream_chat был вызван при use_live=False — не должен"
)
# Один yield с финальным результатом.
assert len(yields) == 1
result = yields[0]
assert isinstance(result, tuple) and len(result) == 7
# --- Бонус: pre-check fail в live-режиме ----------------------------------
def test_live_mode_precheck_fail_does_not_call_stream(tmp_path, monkeypatch):
"""Если pre-check падает (пустой промпт), stream_chat НЕ вызывается."""
monkeypatch.setenv("OMNISVG_DB_PATH", str(tmp_path / "history.sqlite"))
monkeypatch.setenv("OMNISVG_PREVIEW_DIR", str(tmp_path / "previews"))
with patch("app.stream_chat") as mock_stream:
yields = _call_live(prompt="")
assert mock_stream.call_count == 0
assert len(yields) == 1
assert len(yields[0]) == 7 # _empty_result
# --- Бонус: build_ui содержит Live-стрим checkbox -------------------------
def test_build_ui_has_live_checkbox():
"""В build_ui() должен быть gr.Checkbox с label про Live-стрим."""
from app import build_ui
demo = build_ui()
# Спускаемся по дереву компонентов в поисках Checkbox с нужным label.
# В Gradio 5.37 components живут в blocks.blocks.values().
found = False
label_seen: str = ""
for comp in demo.blocks.values():
if getattr(comp, "type", "") == "checkbox" or comp.__class__.__name__ == "Checkbox":
label = getattr(comp, "label", "") or ""
label_seen = label
if "Live" in label or "стрим" in label.lower() or "live" in label.lower():
found = True
break
assert found, (
f"не нашли gr.Checkbox с label 'Live'/'стрим' среди {len(demo.blocks)} "
f"компонентов; последний увиденный label={label_seen!r}"
)