"""Регрессионные тесты на 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": [''], "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