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
|
||||
@@ -0,0 +1,404 @@
|
||||
"""Юнит-тесты для history.py.
|
||||
|
||||
Покрывают:
|
||||
- add(): вставка записи возвращает id, JSON-поля сериализуются
|
||||
- list_recent(): сортировка DESC, лимит, новые сверху
|
||||
- get(): по id, None для несуществующего
|
||||
- persist: создать/закрыть/создать — данные сохранились на диске
|
||||
- count(): корректный счёт
|
||||
- Спецсимволы в prompt: юникод, кавычки, переносы строк, эмодзи
|
||||
- JSON-поля: list[str] с не-ASCII корректно декодируется обратно
|
||||
- WAL mode: PRAGMA journal_mode после открытия соединения
|
||||
- list_recent(limit=...) ограничивает выборку
|
||||
|
||||
Каждый тест использует tmp_path (pytest fixture) для изолированной БД.
|
||||
|
||||
Запуск: `python -m pytest tests/test_history.py -v`
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
import pytest # noqa: E402
|
||||
|
||||
from history import History, Record # noqa: E402
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Базовые CRUD
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_record(**overrides) -> Record:
|
||||
"""Минимальный валидный Record + overrides."""
|
||||
base = dict(
|
||||
prompt="a fox",
|
||||
mode="icon",
|
||||
model="test-model",
|
||||
n_requested=2,
|
||||
n_returned=2,
|
||||
temperature=0.4,
|
||||
status="ok",
|
||||
error_reason=None,
|
||||
raw_outputs=["raw1", "raw2"],
|
||||
validated_outputs=["<svg/>", "<svg/>"],
|
||||
previews=["/p/1.png", "/p/2.png"],
|
||||
best_index=0,
|
||||
)
|
||||
base.update(overrides)
|
||||
return Record(**base)
|
||||
|
||||
|
||||
def test_add_returns_increasing_ids(tmp_path: Path):
|
||||
db = tmp_path / "h.sqlite"
|
||||
with History(db) as h:
|
||||
id1 = h.add(_make_record(prompt="a"))
|
||||
id2 = h.add(_make_record(prompt="b"))
|
||||
id3 = h.add(_make_record(prompt="c"))
|
||||
assert id1 == 1
|
||||
assert id2 == 2
|
||||
assert id3 == 3
|
||||
assert id1 < id2 < id3
|
||||
|
||||
|
||||
def test_get_returns_record_by_id(tmp_path: Path):
|
||||
db = tmp_path / "h.sqlite"
|
||||
with History(db) as h:
|
||||
rid = h.add(_make_record(prompt="hello"))
|
||||
with History(db) as h:
|
||||
rec = h.get(rid)
|
||||
assert rec is not None
|
||||
assert rec["id"] == rid
|
||||
assert rec["prompt"] == "hello"
|
||||
assert rec["mode"] == "icon"
|
||||
assert rec["status"] == "ok"
|
||||
assert rec["n_requested"] == 2
|
||||
assert rec["n_returned"] == 2
|
||||
assert rec["temperature"] == 0.4
|
||||
assert rec["raw_outputs"] == ["raw1", "raw2"]
|
||||
assert rec["validated_outputs"] == ["<svg/>", "<svg/>"]
|
||||
assert rec["previews"] == ["/p/1.png", "/p/2.png"]
|
||||
assert rec["best_index"] == 0
|
||||
|
||||
|
||||
def test_get_returns_none_for_missing_id(tmp_path: Path):
|
||||
db = tmp_path / "h.sqlite"
|
||||
with History(db) as h:
|
||||
assert h.get(99999) is None
|
||||
|
||||
|
||||
def test_count_returns_total(tmp_path: Path):
|
||||
db = tmp_path / "h.sqlite"
|
||||
with History(db) as h:
|
||||
assert h.count() == 0
|
||||
h.add(_make_record(prompt="a"))
|
||||
h.add(_make_record(prompt="b"))
|
||||
h.add(_make_record(prompt="c"))
|
||||
assert h.count() == 3
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# list_recent
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_list_recent_returns_newest_first(tmp_path: Path):
|
||||
"""Новые записи — сверху, лимит работает."""
|
||||
db = tmp_path / "h.sqlite"
|
||||
with History(db) as h:
|
||||
for i in range(5):
|
||||
h.add(_make_record(prompt=f"prompt-{i}"))
|
||||
time.sleep(0.005) # гарантируем уникальный created_at
|
||||
with History(db) as h:
|
||||
rows = h.list_recent(limit=3)
|
||||
assert len(rows) == 3
|
||||
# Новейшие сверху → "prompt-4", "prompt-3", "prompt-2"
|
||||
assert [r["prompt"] for r in rows] == ["prompt-4", "prompt-3", "prompt-2"]
|
||||
|
||||
|
||||
def test_list_recent_default_limit_is_50(tmp_path: Path):
|
||||
db = tmp_path / "h.sqlite"
|
||||
with History(db) as h:
|
||||
for i in range(60):
|
||||
h.add(_make_record(prompt=f"p-{i}"))
|
||||
with History(db) as h:
|
||||
rows = h.list_recent()
|
||||
assert len(rows) == 50
|
||||
|
||||
|
||||
def test_list_recent_handles_explicit_large_limit(tmp_path: Path):
|
||||
db = tmp_path / "h.sqlite"
|
||||
with History(db) as h:
|
||||
for i in range(3):
|
||||
h.add(_make_record(prompt=f"p-{i}"))
|
||||
with History(db) as h:
|
||||
rows = h.list_recent(limit=1000)
|
||||
assert len(rows) == 3
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Persist между открытиями
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_data_persists_across_open_close(tmp_path: Path):
|
||||
"""Создать → закрыть → снова открыть → данные на месте."""
|
||||
db = tmp_path / "h.sqlite"
|
||||
with History(db) as h:
|
||||
rid1 = h.add(_make_record(prompt="first"))
|
||||
rid2 = h.add(_make_record(prompt="second"))
|
||||
# БД закрыта; открываем заново
|
||||
assert db.exists()
|
||||
assert db.stat().st_size > 0
|
||||
with History(db) as h:
|
||||
assert h.count() == 2
|
||||
assert h.get(rid1)["prompt"] == "first"
|
||||
assert h.get(rid2)["prompt"] == "second"
|
||||
|
||||
|
||||
def test_data_survives_full_reopen_with_wal_files(tmp_path: Path):
|
||||
"""WAL-файлы могут остаться после закрытия — повторное открытие должно работать."""
|
||||
db = tmp_path / "h.sqlite"
|
||||
with History(db) as h:
|
||||
h.add(_make_record(prompt="x"))
|
||||
# Проверяем, что нет orphan'ов: всё читается.
|
||||
with History(db) as h:
|
||||
rec = h.get(1)
|
||||
assert rec["prompt"] == "x"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Спецсимволы в prompt
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_prompt_with_unicode_cyrillic(tmp_path: Path):
|
||||
db = tmp_path / "h.sqlite"
|
||||
cyrillic = "лиса в осеннем лесу, палитра оранжевая"
|
||||
with History(db) as h:
|
||||
rid = h.add(_make_record(prompt=cyrillic))
|
||||
with History(db) as h:
|
||||
rec = h.get(rid)
|
||||
assert rec["prompt"] == cyrillic
|
||||
|
||||
|
||||
def test_prompt_with_quotes_and_doublequotes(tmp_path: Path):
|
||||
db = tmp_path / "h.sqlite"
|
||||
p = 'icon with "double" and \'single\' quotes'
|
||||
with History(db) as h:
|
||||
rid = h.add(_make_record(prompt=p))
|
||||
with History(db) as h:
|
||||
rec = h.get(rid)
|
||||
assert rec["prompt"] == p
|
||||
|
||||
|
||||
def test_prompt_with_newlines_and_tabs(tmp_path: Path):
|
||||
db = tmp_path / "h.sqlite"
|
||||
p = "line 1\nline 2\n\tindented\n\nblank-line-above"
|
||||
with History(db) as h:
|
||||
rid = h.add(_make_record(prompt=p))
|
||||
with History(db) as h:
|
||||
rec = h.get(rid)
|
||||
assert rec["prompt"] == p
|
||||
|
||||
|
||||
def test_prompt_with_emoji(tmp_path: Path):
|
||||
db = tmp_path / "h.sqlite"
|
||||
p = "fox 🦊 in forest 🌲🌲"
|
||||
with History(db) as h:
|
||||
rid = h.add(_make_record(prompt=p))
|
||||
with History(db) as h:
|
||||
rec = h.get(rid)
|
||||
assert rec["prompt"] == p
|
||||
|
||||
|
||||
def test_prompt_with_backslashes_and_sql_injection_attempt(tmp_path: Path):
|
||||
"""Промпт с SQL-инъекцией в виде текста — должен храниться как plain text."""
|
||||
db = tmp_path / "h.sqlite"
|
||||
evil = "'; DROP TABLE generations; --"
|
||||
with History(db) as h:
|
||||
rid = h.add(_make_record(prompt=evil))
|
||||
with History(db) as h2:
|
||||
rec = h2.get(rid)
|
||||
assert rec["prompt"] == evil
|
||||
# Таблица жива
|
||||
assert h2.count() == 1
|
||||
|
||||
|
||||
def test_prompt_with_very_long_string(tmp_path: Path):
|
||||
"""Длинный промпт (10K символов) — должен сохраниться без потерь."""
|
||||
db = tmp_path / "h.sqlite"
|
||||
p = "x" * 10000
|
||||
with History(db) as h:
|
||||
rid = h.add(_make_record(prompt=p))
|
||||
with History(db) as h:
|
||||
rec = h.get(rid)
|
||||
assert rec["prompt"] == p
|
||||
assert len(rec["prompt"]) == 10000
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# JSON-поля с не-ASCII
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_raw_outputs_with_unicode_preserved(tmp_path: Path):
|
||||
"""list[str] в raw_outputs хранится как JSON, ensure_ascii=False."""
|
||||
db = tmp_path / "h.sqlite"
|
||||
raw = [
|
||||
'<svg viewBox="0 0 64 64"><text>лиса 🦊</text></svg>',
|
||||
'<svg viewBox="0 0 64 64"><text>simple</text></svg>',
|
||||
]
|
||||
with History(db) as h:
|
||||
rid = h.add(_make_record(raw_outputs=raw))
|
||||
with History(db) as h:
|
||||
rec = h.get(rid)
|
||||
assert rec["raw_outputs"] == raw
|
||||
|
||||
|
||||
def test_validated_outputs_with_unicode_in_svg(tmp_path: Path):
|
||||
db = tmp_path / "h.sqlite"
|
||||
svg = '<svg viewBox="0 0 512 512"><text>Привет</text></svg>'
|
||||
with History(db) as h:
|
||||
rid = h.add(_make_record(validated_outputs=[svg]))
|
||||
with History(db) as h:
|
||||
rec = h.get(rid)
|
||||
assert rec["validated_outputs"] == [svg]
|
||||
|
||||
|
||||
def test_previews_with_unicode_paths(tmp_path: Path):
|
||||
db = tmp_path / "h.sqlite"
|
||||
paths = [
|
||||
"C:\\Users\\пользователь\\превью\\1_0.png",
|
||||
"D:\\AI\\Projects\\лиса\\2_1.png",
|
||||
]
|
||||
with History(db) as h:
|
||||
rid = h.add(_make_record(previews=paths))
|
||||
with History(db) as h:
|
||||
rec = h.get(rid)
|
||||
assert rec["previews"] == paths
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Контекст-менеджер: WAL и поведение при ошибках
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_wal_mode_is_set(tmp_path: Path):
|
||||
"""После открытия History journal_mode должен быть WAL."""
|
||||
db = tmp_path / "h.sqlite"
|
||||
with History(db) as h:
|
||||
# Спросим напрямую через raw-conn.
|
||||
cur = h.conn.execute("PRAGMA journal_mode")
|
||||
mode = cur.fetchone()[0]
|
||||
assert mode.lower() == "wal"
|
||||
|
||||
|
||||
def test_context_manager_closes_connection(tmp_path: Path):
|
||||
db = tmp_path / "h.sqlite"
|
||||
h = History(db)
|
||||
with h as hist:
|
||||
hist.add(_make_record(prompt="x"))
|
||||
# После выхода conn=None
|
||||
assert h._conn is None
|
||||
|
||||
|
||||
def test_context_manager_commit_path_runs_in_exit(tmp_path: Path):
|
||||
"""Без exception — __exit__ коммитит оставшиеся незакоммиченные изменения.
|
||||
|
||||
Замечание: `add()` сам вызывает commit() внутри, поэтому запись из add()
|
||||
сохранится в любом случае. Этот тест проверяет, что exit/close не
|
||||
портит уже закоммиченное и не падает на нормальном пути.
|
||||
"""
|
||||
db = tmp_path / "h.sqlite"
|
||||
h = History(db)
|
||||
with h as hist:
|
||||
hist.add(_make_record(prompt="x"))
|
||||
# Контекст закрылся без exception
|
||||
assert h._conn is None
|
||||
with History(db) as hist2:
|
||||
assert hist2.count() == 1
|
||||
|
||||
|
||||
def test_conn_outside_context_raises(tmp_path: Path):
|
||||
db = tmp_path / "h.sqlite"
|
||||
h = History(db)
|
||||
with pytest.raises(RuntimeError, match="контекст-менеджера"):
|
||||
_ = h.conn
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Нишевые: пустые list'ы, edge-cases значений
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_record_with_empty_lists(tmp_path: Path):
|
||||
"""Record с пустыми raw/validated/previews — должен сохраниться."""
|
||||
db = tmp_path / "h.sqlite"
|
||||
with History(db) as h:
|
||||
rid = h.add(_make_record(
|
||||
status="failed",
|
||||
error_reason="lm studio down",
|
||||
raw_outputs=[],
|
||||
validated_outputs=[],
|
||||
previews=[],
|
||||
best_index=None,
|
||||
))
|
||||
with History(db) as h:
|
||||
rec = h.get(rid)
|
||||
assert rec["raw_outputs"] == []
|
||||
assert rec["validated_outputs"] == []
|
||||
assert rec["previews"] == []
|
||||
assert rec["best_index"] is None
|
||||
assert rec["error_reason"] == "lm studio down"
|
||||
|
||||
|
||||
def test_default_created_at_set_by_add(tmp_path: Path):
|
||||
"""Если created_at=None в Record — add() заполняет time.time()."""
|
||||
db = tmp_path / "h.sqlite"
|
||||
before = time.time()
|
||||
with History(db) as h:
|
||||
rec_in = _make_record(prompt="x")
|
||||
assert rec_in.created_at is None
|
||||
rid = h.add(rec_in)
|
||||
after = time.time()
|
||||
with History(db) as h:
|
||||
rec_out = h.get(rid)
|
||||
assert before - 1 <= rec_out["created_at"] <= after + 1
|
||||
|
||||
|
||||
def test_explicit_created_at_preserved(tmp_path: Path):
|
||||
db = tmp_path / "h.sqlite"
|
||||
fixed_ts = 1700000000.0
|
||||
with History(db) as h:
|
||||
rid = h.add(_make_record(prompt="x", created_at=fixed_ts))
|
||||
with History(db) as h:
|
||||
rec = h.get(rid)
|
||||
assert rec["created_at"] == fixed_ts
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Несколько записей: list_recent с лимитом < N
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_list_recent_limit_smaller_than_total(tmp_path: Path):
|
||||
db = tmp_path / "h.sqlite"
|
||||
with History(db) as h:
|
||||
for i in range(10):
|
||||
h.add(_make_record(prompt=f"p-{i}"))
|
||||
time.sleep(0.003)
|
||||
with History(db) as h:
|
||||
rows = h.list_recent(limit=2)
|
||||
assert len(rows) == 2
|
||||
# Самые свежие
|
||||
assert rows[0]["prompt"] == "p-9"
|
||||
assert rows[1]["prompt"] == "p-8"
|
||||
@@ -0,0 +1,651 @@
|
||||
"""Юнит-тесты для lm_client.py.
|
||||
|
||||
Покрывают:
|
||||
- успешный ответ: парсинг N=4 choice'ов, текст, finish_reasons, model, usage
|
||||
- таймаут: httpx.TimeoutException → LMStudioUnavailable с упоминанием timeout
|
||||
- 5xx от LM Studio: status_code 503 → LMStudioUnavailable
|
||||
- 4xx: status_code 401 / 404 → LMStudioUnavailable (по контракту: ошибка клиента
|
||||
или сервера — нам всё равно)
|
||||
- пустой / битый JSON
|
||||
- ответ без choices → LMStudioUnavailable
|
||||
- ответ с tool_use (content=None, tool_calls есть) → raw_texts содержит пустую строку
|
||||
- ответ с несколькими ```svg блоками: parse_svg идёт в валидаторе, тут проверим,
|
||||
что chat() возвращает всю строку модели как есть в raw_text
|
||||
- generate_svg() высокоуровневая обёртка: проверка сообщений и prompt guard
|
||||
- encode_pil_to_data_url(): кодирует PIL.Image в data: URL
|
||||
- validate_image(): PIL.Image проходит / отклоняется по размеру / формату
|
||||
|
||||
httpx мокается через unittest.mock — клиент создаётся внутри `chat()`,
|
||||
поэтому патчим `httpx.Client`.
|
||||
|
||||
Запуск: `python -m pytest tests/test_lm_client.py -v`
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import io
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
import pytest # noqa: E402
|
||||
|
||||
import httpx # noqa: E402
|
||||
|
||||
from lm_client import ( # noqa: E402
|
||||
DEFAULT_BASE_URL,
|
||||
LMStudioUnavailable,
|
||||
chat,
|
||||
encode_pil_to_data_url,
|
||||
generate_svg,
|
||||
validate_image,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Хелперы для построения mock-ответа httpx
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_response(
|
||||
*,
|
||||
status_code: int = 200,
|
||||
json_payload: dict | None = None,
|
||||
text: str = "",
|
||||
) -> MagicMock:
|
||||
"""Создаёт mock httpx.Response с заданным status_code и JSON-телом."""
|
||||
resp = MagicMock(spec=httpx.Response)
|
||||
resp.status_code = status_code
|
||||
if json_payload is not None:
|
||||
resp.json.return_value = json_payload
|
||||
else:
|
||||
resp.json.side_effect = ValueError("not json")
|
||||
resp.text = text
|
||||
return resp
|
||||
|
||||
|
||||
def _ok_payload(texts: list[str], *, model: str = "test-model") -> dict:
|
||||
"""Стандартный OpenAI-style ответ с N choice'ами."""
|
||||
return {
|
||||
"id": "chatcmpl-test",
|
||||
"object": "chat.completion",
|
||||
"model": model,
|
||||
"choices": [
|
||||
{
|
||||
"index": i,
|
||||
"message": {"role": "assistant", "content": t},
|
||||
"finish_reason": "stop",
|
||||
}
|
||||
for i, t in enumerate(texts)
|
||||
],
|
||||
"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15},
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Успешный путь
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_chat_success_returns_n_texts_and_metadata():
|
||||
"""N=4: возвращаем 4 текста, finish_reasons, model, usage."""
|
||||
payload = _ok_payload(["a", "b", "c", "d"], model="my-model")
|
||||
resp = _make_response(json_payload=payload)
|
||||
|
||||
with patch("lm_client.httpx.Client") as MockClient:
|
||||
mock_inst = MagicMock()
|
||||
mock_inst.post.return_value = resp
|
||||
MockClient.return_value.__enter__.return_value = mock_inst
|
||||
|
||||
result = chat(
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
model="my-model",
|
||||
n=4,
|
||||
temperature=0.5,
|
||||
base_url="http://mock:1234/v1",
|
||||
)
|
||||
|
||||
assert isinstance(result.raw_texts, list)
|
||||
assert len(result.raw_texts) == 4
|
||||
assert result.raw_texts == ["a", "b", "c", "d"]
|
||||
assert result.finish_reasons == ["stop"] * 4
|
||||
assert result.model == "my-model"
|
||||
assert result.usage == {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}
|
||||
assert result.elapsed_s >= 0.0
|
||||
|
||||
# Проверяем, что URL и заголовки формируются правильно.
|
||||
call = mock_inst.post.call_args
|
||||
url = call.args[0] if call.args else call.kwargs["url"]
|
||||
assert url == "http://mock:1234/v1/chat/completions"
|
||||
headers = call.kwargs["headers"]
|
||||
assert headers["Content-Type"] == "application/json"
|
||||
assert headers["Authorization"] == "Bearer lm-studio"
|
||||
body = call.kwargs["json"]
|
||||
assert body["model"] == "my-model"
|
||||
assert body["n"] == 4
|
||||
assert body["temperature"] == 0.5
|
||||
assert body["stream"] is False
|
||||
|
||||
|
||||
def test_chat_success_single_candidate_default():
|
||||
"""Дефолт n=1 — возвращаем ровно один текст."""
|
||||
resp = _make_response(json_payload=_ok_payload(["only one"]))
|
||||
|
||||
with patch("lm_client.httpx.Client") as MockClient:
|
||||
mock_inst = MagicMock()
|
||||
mock_inst.post.return_value = resp
|
||||
MockClient.return_value.__enter__.return_value = mock_inst
|
||||
|
||||
result = chat(
|
||||
messages=[{"role": "user", "content": "x"}],
|
||||
base_url="http://m:1/v1",
|
||||
)
|
||||
|
||||
assert len(result.raw_texts) == 1
|
||||
assert result.raw_texts[0] == "only one"
|
||||
|
||||
|
||||
def test_chat_server_returns_fewer_choices_pads_with_empty():
|
||||
"""Сервер вернул 1 из 4 — добиваем пустыми строками и 'missing'."""
|
||||
payload = _ok_payload(["one"]) # n=4, но пришёл только 1
|
||||
resp = _make_response(json_payload=payload)
|
||||
|
||||
with patch("lm_client.httpx.Client") as MockClient:
|
||||
mock_inst = MagicMock()
|
||||
mock_inst.post.return_value = resp
|
||||
MockClient.return_value.__enter__.return_value = mock_inst
|
||||
|
||||
result = chat(
|
||||
messages=[{"role": "user", "content": "x"}],
|
||||
n=4,
|
||||
base_url="http://m:1/v1",
|
||||
)
|
||||
|
||||
assert len(result.raw_texts) == 4
|
||||
assert result.raw_texts[0] == "one"
|
||||
assert result.raw_texts[1:] == ["", "", ""]
|
||||
assert result.finish_reasons == ["stop", "missing", "missing", "missing"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ошибочные пути
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_chat_timeout_raises_lmstudio_unavailable():
|
||||
"""httpx.TimeoutException → LMStudioUnavailable, в тексте есть 'timeout'."""
|
||||
with patch("lm_client.httpx.Client") as MockClient:
|
||||
mock_inst = MagicMock()
|
||||
mock_inst.post.side_effect = httpx.TimeoutException("timed out")
|
||||
MockClient.return_value.__enter__.return_value = mock_inst
|
||||
|
||||
with pytest.raises(LMStudioUnavailable) as excinfo:
|
||||
chat(
|
||||
messages=[{"role": "user", "content": "x"}],
|
||||
n=1,
|
||||
base_url="http://m:1/v1",
|
||||
timeout_s=5.0,
|
||||
)
|
||||
msg = str(excinfo.value).lower()
|
||||
assert "timeout" in msg
|
||||
# Код форматирует "5.0с" / "5с" — проверим, что число таймаута попало в сообщение.
|
||||
assert "5" in msg
|
||||
assert "с" in str(excinfo.value) # "превысил 5с"
|
||||
|
||||
|
||||
def test_chat_5xx_raises_lmstudio_unavailable():
|
||||
resp = _make_response(status_code=503, text="service unavailable")
|
||||
|
||||
with patch("lm_client.httpx.Client") as MockClient:
|
||||
mock_inst = MagicMock()
|
||||
mock_inst.post.return_value = resp
|
||||
MockClient.return_value.__enter__.return_value = mock_inst
|
||||
|
||||
with pytest.raises(LMStudioUnavailable) as excinfo:
|
||||
chat(
|
||||
messages=[{"role": "user", "content": "x"}],
|
||||
base_url="http://m:1/v1",
|
||||
)
|
||||
msg = str(excinfo.value)
|
||||
assert "503" in msg
|
||||
assert "service unavailable"[:30] in msg or "service unav" in msg
|
||||
|
||||
|
||||
def test_chat_500_raises_lmstudio_unavailable():
|
||||
resp = _make_response(status_code=500, text="internal error")
|
||||
|
||||
with patch("lm_client.httpx.Client") as MockClient:
|
||||
mock_inst = MagicMock()
|
||||
mock_inst.post.return_value = resp
|
||||
MockClient.return_value.__enter__.return_value = mock_inst
|
||||
|
||||
with pytest.raises(LMStudioUnavailable) as excinfo:
|
||||
chat(
|
||||
messages=[{"role": "user", "content": "x"}],
|
||||
base_url="http://m:1/v1",
|
||||
)
|
||||
assert "500" in str(excinfo.value)
|
||||
|
||||
|
||||
def test_chat_4xx_raises_lmstudio_unavailable():
|
||||
"""4xx — наша ошибка, но клиент всё равно бросает LMStudioUnavailable."""
|
||||
resp = _make_response(status_code=401, text="unauthorized")
|
||||
|
||||
with patch("lm_client.httpx.Client") as MockClient:
|
||||
mock_inst = MagicMock()
|
||||
mock_inst.post.return_value = resp
|
||||
MockClient.return_value.__enter__.return_value = mock_inst
|
||||
|
||||
with pytest.raises(LMStudioUnavailable) as excinfo:
|
||||
chat(
|
||||
messages=[{"role": "user", "content": "x"}],
|
||||
base_url="http://m:1/v1",
|
||||
)
|
||||
assert "401" in str(excinfo.value)
|
||||
|
||||
|
||||
def test_chat_network_error_raises_lmstudio_unavailable():
|
||||
"""Любой httpx.HTTPError (ConnectionError и пр.) → LMStudioUnavailable."""
|
||||
with patch("lm_client.httpx.Client") as MockClient:
|
||||
mock_inst = MagicMock()
|
||||
mock_inst.post.side_effect = httpx.ConnectError("connection refused")
|
||||
MockClient.return_value.__enter__.return_value = mock_inst
|
||||
|
||||
with pytest.raises(LMStudioUnavailable) as excinfo:
|
||||
chat(
|
||||
messages=[{"role": "user", "content": "x"}],
|
||||
base_url="http://m:1/v1",
|
||||
)
|
||||
msg = str(excinfo.value).lower()
|
||||
assert "недоступен" in msg or "unavailable" in msg
|
||||
assert "connection refused" in msg
|
||||
|
||||
|
||||
def test_chat_invalid_json_raises_lmstudio_unavailable():
|
||||
"""200 OK, но тело — не JSON."""
|
||||
resp = _make_response(status_code=200, text="<html>not json</html>")
|
||||
|
||||
with patch("lm_client.httpx.Client") as MockClient:
|
||||
mock_inst = MagicMock()
|
||||
mock_inst.post.return_value = resp
|
||||
MockClient.return_value.__enter__.return_value = mock_inst
|
||||
|
||||
with pytest.raises(LMStudioUnavailable) as excinfo:
|
||||
chat(
|
||||
messages=[{"role": "user", "content": "x"}],
|
||||
base_url="http://m:1/v1",
|
||||
)
|
||||
assert "не-JSON" in str(excinfo.value) or "json" in str(excinfo.value).lower()
|
||||
|
||||
|
||||
def test_chat_empty_choices_raises_lmstudio_unavailable():
|
||||
"""200 OK, choices пустой → ошибка."""
|
||||
payload = {"choices": []}
|
||||
resp = _make_response(json_payload=payload)
|
||||
|
||||
with patch("lm_client.httpx.Client") as MockClient:
|
||||
mock_inst = MagicMock()
|
||||
mock_inst.post.return_value = resp
|
||||
MockClient.return_value.__enter__.return_value = mock_inst
|
||||
|
||||
with pytest.raises(LMStudioUnavailable) as excinfo:
|
||||
chat(
|
||||
messages=[{"role": "user", "content": "x"}],
|
||||
base_url="http://m:1/v1",
|
||||
)
|
||||
assert "choice" in str(excinfo.value).lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Специфические кейсы: tool_use, контент None, несколько svg-блоков
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_chat_tool_use_response_yields_empty_string_candidate():
|
||||
"""Модель вернула tool_calls без content → raw_texts содержит '' для этого кандидата."""
|
||||
payload = {
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "1",
|
||||
"type": "function",
|
||||
"function": {"name": "x", "arguments": "{}"},
|
||||
}
|
||||
],
|
||||
},
|
||||
"finish_reason": "tool_calls",
|
||||
},
|
||||
{
|
||||
"index": 1,
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": "<svg viewBox='0 0 64 64'/>",
|
||||
},
|
||||
"finish_reason": "stop",
|
||||
},
|
||||
]
|
||||
}
|
||||
resp = _make_response(json_payload=payload)
|
||||
|
||||
with patch("lm_client.httpx.Client") as MockClient:
|
||||
mock_inst = MagicMock()
|
||||
mock_inst.post.return_value = resp
|
||||
MockClient.return_value.__enter__.return_value = mock_inst
|
||||
|
||||
result = chat(
|
||||
messages=[{"role": "user", "content": "x"}],
|
||||
n=2,
|
||||
base_url="http://m:1/v1",
|
||||
)
|
||||
|
||||
# 1-й кандидат — пусто, 2-й — реальный svg.
|
||||
assert result.raw_texts[0] == ""
|
||||
assert result.raw_texts[1] == "<svg viewBox='0 0 64 64'/>"
|
||||
assert result.finish_reasons[0] == "tool_calls"
|
||||
|
||||
|
||||
def test_chat_response_with_multiple_svg_blocks_kept_as_raw_text():
|
||||
"""Модель вернула ответ с несколькими ```svg блоками внутри — chat() должен
|
||||
сохранить текст as-is. Парсинг — забота validator.extract_svg()."""
|
||||
multi_svg_text = (
|
||||
"Here are some variants:\n"
|
||||
"```svg\n<svg viewBox='0 0 64 64'><circle cx='10' cy='10' r='5'/></svg>\n```\n"
|
||||
"And another:\n"
|
||||
"```svg\n<svg viewBox='0 0 64 64'><rect width='10' height='10'/></svg>\n```"
|
||||
)
|
||||
resp = _make_response(json_payload=_ok_payload([multi_svg_text] * 2))
|
||||
|
||||
with patch("lm_client.httpx.Client") as MockClient:
|
||||
mock_inst = MagicMock()
|
||||
mock_inst.post.return_value = resp
|
||||
MockClient.return_value.__enter__.return_value = mock_inst
|
||||
|
||||
result = chat(
|
||||
messages=[{"role": "user", "content": "x"}],
|
||||
n=2,
|
||||
base_url="http://m:1/v1",
|
||||
)
|
||||
|
||||
# chat() НЕ парсит SVG — оба кандидата идентичны.
|
||||
assert len(result.raw_texts) == 2
|
||||
for raw in result.raw_texts:
|
||||
assert raw.count("<svg") == 2
|
||||
assert "Here are some variants" in raw
|
||||
|
||||
|
||||
def test_chat_content_as_list_of_parts_joined():
|
||||
"""content может быть list[dict] (мультимодальный ответ) — склеиваем текстовые части."""
|
||||
payload = {
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "text", "text": "Hello "},
|
||||
{"type": "text", "text": "world"},
|
||||
],
|
||||
},
|
||||
"finish_reason": "stop",
|
||||
}
|
||||
]
|
||||
}
|
||||
resp = _make_response(json_payload=payload)
|
||||
|
||||
with patch("lm_client.httpx.Client") as MockClient:
|
||||
mock_inst = MagicMock()
|
||||
mock_inst.post.return_value = resp
|
||||
MockClient.return_value.__enter__.return_value = mock_inst
|
||||
|
||||
result = chat(
|
||||
messages=[{"role": "user", "content": "x"}],
|
||||
n=1,
|
||||
base_url="http://m:1/v1",
|
||||
)
|
||||
|
||||
assert result.raw_texts == ["Hello world"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# generate_svg() — высокоуровневая обёртка
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_generate_svg_rejects_empty_prompt():
|
||||
with pytest.raises(ValueError, match="1 до 1000"):
|
||||
generate_svg("", model="x")
|
||||
|
||||
|
||||
def test_generate_svg_rejects_too_long_prompt():
|
||||
with pytest.raises(ValueError, match="1 до 1000"):
|
||||
generate_svg("x" * 1001, model="x")
|
||||
|
||||
|
||||
def test_generate_svg_with_image_sends_multimodal_payload():
|
||||
"""С image_b64 идёт мультимодальный user-message: text + image_url."""
|
||||
svg_text = "<svg viewBox='0 0 64 64'/>"
|
||||
resp = _make_response(json_payload=_ok_payload([svg_text]))
|
||||
|
||||
with patch("lm_client.chat") as mock_chat:
|
||||
mock_chat.return_value = type("R", (), {
|
||||
"raw_texts": [svg_text],
|
||||
"elapsed_s": 0.05,
|
||||
"model": "x",
|
||||
"usage": None,
|
||||
"finish_reasons": ["stop"],
|
||||
})()
|
||||
|
||||
results = generate_svg(
|
||||
"a fox",
|
||||
image_b64="data:image/png;base64,AAA",
|
||||
mode="icon",
|
||||
n=1,
|
||||
temperature=0.4,
|
||||
model="x",
|
||||
system_prompt="<system>icon</system>",
|
||||
)
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0]["svg_text"] == svg_text
|
||||
assert results[0]["raw"] == svg_text
|
||||
assert results[0]["finish_reason"] == "stop"
|
||||
|
||||
# Что ушло в chat()
|
||||
call = mock_chat.call_args
|
||||
msgs = call.kwargs["messages"]
|
||||
assert msgs[0]["role"] == "system"
|
||||
assert msgs[0]["content"] == "<system>icon</system>"
|
||||
assert msgs[1]["role"] == "user"
|
||||
user_content = msgs[1]["content"]
|
||||
assert isinstance(user_content, list)
|
||||
assert user_content[0]["type"] == "text"
|
||||
assert user_content[0]["text"] == "a fox"
|
||||
assert user_content[1]["type"] == "image_url"
|
||||
assert user_content[1]["image_url"]["url"] == "data:image/png;base64,AAA"
|
||||
assert call.kwargs["model"] == "x"
|
||||
assert call.kwargs["n"] == 1
|
||||
assert call.kwargs["temperature"] == 0.4
|
||||
|
||||
|
||||
def test_generate_svg_text_only_sends_string_content():
|
||||
"""Без image_b64 user.content — просто строка."""
|
||||
with patch("lm_client.chat") as mock_chat:
|
||||
mock_chat.return_value = type("R", (), {
|
||||
"raw_texts": ["<svg/>"],
|
||||
"elapsed_s": 0.0,
|
||||
"model": "x",
|
||||
"usage": None,
|
||||
"finish_reasons": ["stop"],
|
||||
})()
|
||||
|
||||
generate_svg("hello", n=1, model="x")
|
||||
|
||||
msgs = mock_chat.call_args.kwargs["messages"]
|
||||
user_msg = msgs[0] # без system_prompt единственное user-сообщение
|
||||
assert user_msg["role"] == "user"
|
||||
assert user_msg["content"] == "hello"
|
||||
|
||||
|
||||
def test_generate_svg_propagates_lmstudio_error():
|
||||
"""Если chat() упал, generate_svg пробрасывает LMStudioUnavailable."""
|
||||
with patch("lm_client.chat") as mock_chat:
|
||||
mock_chat.side_effect = LMStudioUnavailable("upstream timeout")
|
||||
|
||||
with pytest.raises(LMStudioUnavailable, match="upstream timeout"):
|
||||
generate_svg("hello", n=1, model="x")
|
||||
|
||||
|
||||
def test_chat_rejects_n_less_than_one():
|
||||
with pytest.raises(ValueError, match="n должно быть"):
|
||||
chat(messages=[{"role": "user", "content": "x"}], n=0, base_url="http://m:1/v1")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# encode_pil_to_data_url
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_encode_pil_to_data_url_produces_data_url_with_png_mime():
|
||||
from PIL import Image
|
||||
|
||||
img = Image.new("RGB", (10, 10), color=(255, 0, 0))
|
||||
url = encode_pil_to_data_url(img, mime="image/png")
|
||||
assert url.startswith("data:image/png;base64,")
|
||||
# base64 должен корректно декодироваться обратно
|
||||
payload = url.split(",", 1)[1]
|
||||
decoded = base64.b64decode(payload)
|
||||
assert decoded.startswith(b"\x89PNG")
|
||||
# и это валидный PNG
|
||||
Image.open(io.BytesIO(decoded))
|
||||
|
||||
|
||||
def test_encode_pil_to_data_url_jpeg_mime_converts_rgba():
|
||||
"""При mime=jpeg и RGBA → конвертируем в RGB (иначе JPEG не съест)."""
|
||||
from PIL import Image
|
||||
|
||||
img = Image.new("RGBA", (8, 8), color=(0, 255, 0, 128))
|
||||
url = encode_pil_to_data_url(img, mime="image/jpeg")
|
||||
assert url.startswith("data:image/jpeg;base64,")
|
||||
payload = url.split(",", 1)[1]
|
||||
decoded = base64.b64decode(payload)
|
||||
# JPEG стартует с FFD8
|
||||
assert decoded.startswith(b"\xff\xd8")
|
||||
|
||||
|
||||
def test_encode_pil_to_data_url_png_mime_adds_alpha_if_other_mode():
|
||||
"""PNG с mime=png и mode=L (grayscale) → конвертируем в RGBA."""
|
||||
from PIL import Image
|
||||
|
||||
img = Image.new("L", (4, 4), color=128)
|
||||
url = encode_pil_to_data_url(img, mime="image/png")
|
||||
assert url.startswith("data:image/png;base64,")
|
||||
# Просто проверим, что получили валидный PNG
|
||||
payload = url.split(",", 1)[1]
|
||||
decoded = base64.b64decode(payload)
|
||||
assert decoded.startswith(b"\x89PNG")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# validate_image
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_validate_image_passes_normal_png():
|
||||
from PIL import Image
|
||||
|
||||
img = Image.new("RGB", (100, 100), color=(0, 0, 0))
|
||||
validate_image(img) # не бросает
|
||||
|
||||
|
||||
def test_validate_image_rejects_oversized_dimensions():
|
||||
from PIL import Image
|
||||
|
||||
img = Image.new("RGB", (5000, 100), color=(0, 0, 0))
|
||||
with pytest.raises(ValueError, match="слишком большое"):
|
||||
validate_image(img, max_side=4096)
|
||||
|
||||
|
||||
def test_validate_image_rejects_too_many_bytes():
|
||||
"""Картинка проходит по стороне, но approx-байты > max_bytes.
|
||||
|
||||
Берём 4000x4000 (под max_side=4096) и max_bytes=1MB:
|
||||
4000*4000*4 + 64KB = 64MB+ >> 1MB → должно сработать байтовое ограничение.
|
||||
"""
|
||||
from PIL import Image
|
||||
|
||||
img = Image.new("RGB", (4000, 4000), color=(0, 0, 0))
|
||||
with pytest.raises(ValueError, match="МБ"):
|
||||
validate_image(img, max_bytes=1 * 1024 * 1024)
|
||||
|
||||
|
||||
def test_validate_image_rejects_unsupported_format():
|
||||
from PIL import Image
|
||||
|
||||
img = Image.new("RGB", (10, 10), color=(0, 0, 0))
|
||||
img.format = "BMP" # притворяемся BMP
|
||||
with pytest.raises(ValueError, match="неподдерживаемый формат"):
|
||||
validate_image(img)
|
||||
|
||||
|
||||
def test_validate_image_rejects_none():
|
||||
with pytest.raises(ValueError, match="не передано"):
|
||||
validate_image(None) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_validate_image_rejects_non_pil():
|
||||
with pytest.raises(ValueError, match="ожидался PIL.Image"):
|
||||
validate_image("not-an-image") # type: ignore[arg-type]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Минорный: payload включает n/temperature/max_tokens/stream=False
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_chat_payload_contains_required_fields():
|
||||
resp = _make_response(json_payload=_ok_payload(["x"]))
|
||||
with patch("lm_client.httpx.Client") as MockClient:
|
||||
mock_inst = MagicMock()
|
||||
mock_inst.post.return_value = resp
|
||||
MockClient.return_value.__enter__.return_value = mock_inst
|
||||
|
||||
chat(
|
||||
messages=[{"role": "user", "content": "x"}],
|
||||
n=2,
|
||||
temperature=0.7,
|
||||
max_tokens=2048,
|
||||
base_url="http://m:1/v1",
|
||||
)
|
||||
|
||||
body = mock_inst.post.call_args.kwargs["json"]
|
||||
assert body["n"] == 2
|
||||
assert body["temperature"] == 0.7
|
||||
assert body["max_tokens"] == 2048
|
||||
assert body["stream"] is False
|
||||
assert body["model"] # non-empty
|
||||
|
||||
|
||||
def test_chat_uses_default_base_url_when_env_unset(monkeypatch):
|
||||
"""Если base_url=None и env не задан, идём на DEFAULT_BASE_URL."""
|
||||
monkeypatch.delenv("LM_STUDIO_BASE_URL", raising=False)
|
||||
resp = _make_response(json_payload=_ok_payload(["x"]))
|
||||
with patch("lm_client.httpx.Client") as MockClient:
|
||||
mock_inst = MagicMock()
|
||||
mock_inst.post.return_value = resp
|
||||
MockClient.return_value.__enter__.return_value = mock_inst
|
||||
|
||||
chat(messages=[{"role": "user", "content": "x"}], n=1)
|
||||
|
||||
url = mock_inst.post.call_args.args[0]
|
||||
assert url == f"{DEFAULT_BASE_URL}/chat/completions"
|
||||
@@ -0,0 +1,345 @@
|
||||
"""Юнит-тесты для prompts.py.
|
||||
|
||||
Покрывают:
|
||||
- load_system_prompt("icon") → строка из system_icon.txt
|
||||
- load_system_prompt("illustration") → строка из system_illustration.txt
|
||||
- load_system_prompt("junk") → ValueError
|
||||
- load_few_shot() → пары {role, content}, валидные, идут в нужном порядке
|
||||
- build_messages(icon) → первое сообщение system (правильный файл),
|
||||
затем few-shot пары, затем финальный user.
|
||||
- build_messages(illustration) → system — из system_illustration.txt.
|
||||
- build_messages с image_b64 → последний user — list[dict] с text + image_url,
|
||||
текст содержит "Recreate the visual content...".
|
||||
- build_messages без image_b64 → последний user — простая строка.
|
||||
- build_messages с palette → "Palette: ..." добавлено в текст.
|
||||
- build_messages с пустым промптом → ValueError.
|
||||
- build_messages с неизвестным mode → ValueError.
|
||||
- Контракт: system промпт подставляется ИЗ ПРАВИЛЬНОГО ФАЙЛА (содержимое
|
||||
различается для icon и illustration).
|
||||
- Few-shot включён: в messages их пары.
|
||||
|
||||
Запуск: `python -m pytest tests/test_prompts.py -v`
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
import pytest # noqa: E402
|
||||
|
||||
from prompts import ( # noqa: E402
|
||||
build_messages,
|
||||
load_few_shot,
|
||||
load_system_prompt,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# load_system_prompt
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_load_system_prompt_icon_returns_icon_text():
|
||||
"""system_icon.txt: первая строка упоминает 'icon designer'."""
|
||||
text = load_system_prompt("icon")
|
||||
assert isinstance(text, str)
|
||||
assert len(text) > 50
|
||||
assert "icon" in text.lower()
|
||||
# Уникальное для icon: "OmniSVG-Icon" (различается от "OmniSVG-Illustration")
|
||||
assert "OmniSVG-Icon" in text
|
||||
|
||||
|
||||
def test_load_system_prompt_illustration_returns_illustration_text():
|
||||
"""system_illustration.txt: первая строка упоминает illustrator."""
|
||||
text = load_system_prompt("illustration")
|
||||
assert isinstance(text, str)
|
||||
assert len(text) > 50
|
||||
assert "illustration" in text.lower()
|
||||
assert "OmniSVG-Illustration" in text
|
||||
|
||||
|
||||
def test_load_system_prompt_distinct_for_each_mode():
|
||||
"""Содержимое system для icon и illustration — разные файлы."""
|
||||
icon_txt = load_system_prompt("icon")
|
||||
ill_txt = load_system_prompt("illustration")
|
||||
assert icon_txt != ill_txt
|
||||
|
||||
|
||||
def test_load_system_prompt_unknown_mode_raises():
|
||||
with pytest.raises(ValueError, match="неизвестный mode"):
|
||||
load_system_prompt("portrait")
|
||||
with pytest.raises(ValueError, match="неизвестный mode"):
|
||||
load_system_prompt("")
|
||||
|
||||
|
||||
def test_load_system_prompt_strips_trailing_whitespace():
|
||||
"""Файл может заканчиваться на перевод строки — strip() его убирает."""
|
||||
text = load_system_prompt("icon")
|
||||
assert not text.endswith("\n")
|
||||
assert not text.endswith(" ")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# load_few_shot
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_load_few_shot_returns_list_of_message_dicts():
|
||||
msgs = load_few_shot()
|
||||
assert isinstance(msgs, list)
|
||||
assert len(msgs) > 0
|
||||
for m in msgs:
|
||||
assert isinstance(m, dict)
|
||||
assert m["role"] in ("user", "assistant")
|
||||
assert isinstance(m["content"], str)
|
||||
assert len(m["content"]) > 0
|
||||
|
||||
|
||||
def test_load_few_shot_alternates_user_assistant():
|
||||
"""USER → ASSISTANT → USER → ASSISTANT … (по дизайну)."""
|
||||
msgs = load_few_shot()
|
||||
for i, m in enumerate(msgs):
|
||||
expected = "user" if i % 2 == 0 else "assistant"
|
||||
assert m["role"] == expected, (
|
||||
f"индекс {i}: ожидался role={expected}, получен {m['role']}"
|
||||
)
|
||||
|
||||
|
||||
def test_load_few_shot_includes_refusal_example():
|
||||
"""Последний assistant — это refusal без <svg> (по дизайну)."""
|
||||
msgs = load_few_shot()
|
||||
# Ищем любой assistant-блок, который не содержит <svg>
|
||||
refusals = [m for m in msgs if m["role"] == "assistant" and "<svg" not in m["content"]]
|
||||
assert len(refusals) >= 1, "few-shot должен включать хотя бы один refusal"
|
||||
|
||||
|
||||
def test_load_few_shot_includes_svg_examples():
|
||||
"""Среди assistant'ов есть и реальные <svg>...</svg>."""
|
||||
msgs = load_few_shot()
|
||||
svg_assistants = [m for m in msgs if m["role"] == "assistant" and "<svg" in m["content"]]
|
||||
assert len(svg_assistants) >= 2 # минимум 2 иконки (по дизайну)
|
||||
|
||||
|
||||
def test_load_few_shot_is_stable_across_calls():
|
||||
"""load_few_shot() детерминирован — не возвращает разное при повторе."""
|
||||
first = load_few_shot()
|
||||
second = load_few_shot()
|
||||
assert first == second
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# build_messages — icon
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_build_messages_icon_has_system_first():
|
||||
"""messages[0] — system с правильным содержимым (system_icon.txt)."""
|
||||
msgs = build_messages("hello", mode="icon", n=1, temperature=0.4)
|
||||
assert msgs[0]["role"] == "system"
|
||||
# Содержимое — ровно из system_icon.txt
|
||||
assert msgs[0]["content"] == load_system_prompt("icon")
|
||||
|
||||
|
||||
def test_build_messages_icon_has_few_shot_pairs_after_system():
|
||||
"""После system идут few-shot пары."""
|
||||
msgs = build_messages("hello", mode="icon", n=1, temperature=0.4)
|
||||
few_shot = load_few_shot()
|
||||
# Сразу после system и до последнего user
|
||||
assert msgs[1:1 + len(few_shot)] == few_shot
|
||||
|
||||
|
||||
def test_build_messages_icon_last_message_is_user_with_prompt():
|
||||
"""Последнее сообщение — user, content = prompt (без image)."""
|
||||
msgs = build_messages("the magnifier", mode="icon", n=1, temperature=0.4)
|
||||
last = msgs[-1]
|
||||
assert last["role"] == "user"
|
||||
assert last["content"] == "the magnifier"
|
||||
|
||||
|
||||
def test_build_messages_icon_prompt_is_stripped():
|
||||
"""Пробелы по краям — strip'аются."""
|
||||
msgs = build_messages(" hello \n", mode="icon")
|
||||
assert msgs[-1]["content"] == "hello"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# build_messages — illustration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_build_messages_illustration_has_illustration_system():
|
||||
"""system для illustration — из правильного файла."""
|
||||
msgs = build_messages("a fox", mode="illustration", n=1, temperature=0.4)
|
||||
assert msgs[0]["role"] == "system"
|
||||
assert msgs[0]["content"] == load_system_prompt("illustration")
|
||||
assert "OmniSVG-Illustration" in msgs[0]["content"]
|
||||
|
||||
|
||||
def test_build_messages_illustration_uses_same_few_shot():
|
||||
"""Few-shot одинаков для обоих mode (только system различается)."""
|
||||
icon_msgs = build_messages("hi", mode="icon")
|
||||
ill_msgs = build_messages("hi", mode="illustration")
|
||||
# Содержимое system разное
|
||||
assert icon_msgs[0]["content"] != ill_msgs[0]["content"]
|
||||
# Few-shot — одинаковый (без system: индекс 1..1+len(fs))
|
||||
fs = load_few_shot()
|
||||
assert icon_msgs[1:1 + len(fs)] == ill_msgs[1:1 + len(fs)]
|
||||
assert icon_msgs[1:1 + len(fs)] == fs
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# build_messages — image (multimodal)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_build_messages_with_image_uses_multimodal_content():
|
||||
"""image_b64 задан → последний user.content — list[dict] с text+image_url."""
|
||||
msgs = build_messages(
|
||||
"describe",
|
||||
mode="icon",
|
||||
image_b64="data:image/png;base64,ZZZ",
|
||||
n=1,
|
||||
temperature=0.4,
|
||||
)
|
||||
last = msgs[-1]
|
||||
assert last["role"] == "user"
|
||||
assert isinstance(last["content"], list)
|
||||
assert last["content"][0]["type"] == "text"
|
||||
assert last["content"][1]["type"] == "image_url"
|
||||
assert last["content"][1]["image_url"]["url"] == "data:image/png;base64,ZZZ"
|
||||
|
||||
|
||||
def test_build_messages_with_image_text_includes_recreate_instruction():
|
||||
"""С image_b64 в text идёт преамбула 'Recreate the visual content...'."""
|
||||
msgs = build_messages(
|
||||
"my prompt",
|
||||
mode="icon",
|
||||
image_b64="data:image/png;base64,X",
|
||||
)
|
||||
last = msgs[-1]
|
||||
text_part = last["content"][0]["text"]
|
||||
assert "Recreate the visual content" in text_part
|
||||
assert "my prompt" in text_part
|
||||
|
||||
|
||||
def test_build_messages_without_image_uses_string_content():
|
||||
"""Без image_b64 — последний user.content = строка."""
|
||||
msgs = build_messages("just text", mode="icon")
|
||||
last = msgs[-1]
|
||||
assert isinstance(last["content"], str)
|
||||
assert last["content"] == "just text"
|
||||
# И НЕ содержит "Recreate" (этот преамбул только для image-режима)
|
||||
assert "Recreate" not in last["content"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# build_messages — palette
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_build_messages_with_palette_appends_palette_line():
|
||||
"""palette добавляется отдельной строкой 'Palette: ...' в user text."""
|
||||
msgs = build_messages(
|
||||
"a fox",
|
||||
mode="icon",
|
||||
palette="blue and teal",
|
||||
)
|
||||
last = msgs[-1]
|
||||
text = last["content"]
|
||||
assert "a fox" in text
|
||||
assert "Palette: blue and teal" in text
|
||||
|
||||
|
||||
def test_build_messages_with_palette_in_multimodal():
|
||||
"""Palette добавляется и в multimodal-режиме (в text-часть)."""
|
||||
msgs = build_messages(
|
||||
"a fox",
|
||||
mode="illustration",
|
||||
image_b64="data:image/png;base64,X",
|
||||
palette="warm autumn",
|
||||
)
|
||||
last = msgs[-1]
|
||||
text_part = last["content"][0]["text"]
|
||||
assert "a fox" in text_part
|
||||
assert "Palette: warm autumn" in text_part
|
||||
assert "Recreate" in text_part
|
||||
|
||||
|
||||
def test_build_messages_palette_stripped():
|
||||
"""palette.strip() — пробелы по краям убираются."""
|
||||
msgs = build_messages("x", mode="icon", palette=" blue \n")
|
||||
assert "Palette: blue" in msgs[-1]["content"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# build_messages — валидация входа
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_build_messages_empty_prompt_raises():
|
||||
with pytest.raises(ValueError, match="промпт пустой"):
|
||||
build_messages("", mode="icon")
|
||||
|
||||
|
||||
def test_build_messages_whitespace_only_prompt_raises():
|
||||
"""Промпт из одних пробелов — тоже пустой."""
|
||||
with pytest.raises(ValueError, match="промпт пустой"):
|
||||
build_messages(" \n\t ", mode="icon")
|
||||
|
||||
|
||||
def test_build_messages_unknown_mode_raises():
|
||||
with pytest.raises(ValueError, match="неизвестный mode"):
|
||||
build_messages("hi", mode="portrait")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# build_messages — параметры n и temperature
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_build_messages_n_and_temperature_do_not_appear_in_messages():
|
||||
"""n и temperature — параметры API, не подставляются в content.
|
||||
|
||||
(По дизайну: они идут в payload, а в messages их нет.)
|
||||
"""
|
||||
msgs = build_messages("hi", mode="icon", n=4, temperature=0.7)
|
||||
full_text = " ".join(
|
||||
m["content"] if isinstance(m["content"], str) else str(m["content"])
|
||||
for m in msgs
|
||||
)
|
||||
assert "n=4" not in full_text
|
||||
assert "temperature" not in full_text or "temperature" in load_system_prompt("icon")
|
||||
# Проверяем, что temperature вообще не появляется в user-сообщениях.
|
||||
for m in msgs:
|
||||
if m["role"] == "user" and m is not msgs[0]: # system пропускаем
|
||||
content = m["content"]
|
||||
if isinstance(content, str):
|
||||
assert "temperature" not in content
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Полная структура: порядок сообщений
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_build_messages_message_order():
|
||||
"""Структура: system, few-shot пары, финальный user."""
|
||||
msgs = build_messages("final", mode="icon")
|
||||
fs = load_few_shot()
|
||||
# Длина = 1 (system) + len(few_shot) + 1 (final user)
|
||||
assert len(msgs) == 1 + len(fs) + 1
|
||||
# system — первый
|
||||
assert msgs[0]["role"] == "system"
|
||||
# Последний — user
|
||||
assert msgs[-1]["role"] == "user"
|
||||
# Последний user содержит наш промпт
|
||||
assert "final" in (
|
||||
msgs[-1]["content"]
|
||||
if isinstance(msgs[-1]["content"], str)
|
||||
else msgs[-1]["content"][0]["text"]
|
||||
)
|
||||
@@ -0,0 +1,248 @@
|
||||
"""Юнит-тесты для renderer.py.
|
||||
|
||||
Покрывают:
|
||||
- render_png() с валидным SVG → либо PNG bytes, либо None (если cairo недоступен)
|
||||
Документируем: функция НЕ бросает — всегда возвращает bytes | None.
|
||||
- render_png() с пустым/None входом → None
|
||||
- render_png() с невалидным SVG (синтаксически сломан) → None (cairo упадёт,
|
||||
renderer ловит)
|
||||
- render_png() с SVG без xmlns → корректно добавляет xmlns (cairo требует)
|
||||
- save_png() создаёт каталог при отсутствии и пишет файл с ожидаемым путём
|
||||
- save_png() с произвольными PNG-байтами сохраняет as-is (без декодирования)
|
||||
- Интеграция: render_png + save_png end-to-end (если cairo есть) либо
|
||||
save_png-fallback (если cairo нет)
|
||||
|
||||
ЗАМЕЧАНИЕ ПОВЕДЕНИЯ (документируем в этом тесте):
|
||||
Если `cairo-2.dll`/`libcairo` недоступен — `render_png()` возвращает `None`,
|
||||
не бросает. Это явный контракт из дизайн-доки §6: "Если cairo недоступен
|
||||
… `render_png()` возвращает `None`". Невалидный SVG: если cairo есть — cairosvg
|
||||
бросит Exception, renderer ловит его через `except Exception` и возвращает
|
||||
`None`. Если cairo нет — мы и так сразу возвращаем `None`.
|
||||
|
||||
Запуск: `python -m pytest tests/test_renderer.py -v`
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
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
|
||||
|
||||
from renderer import _get_cairosvg, render_png, save_png # noqa: E402
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Документация поведения: пустой/None вход
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_render_png_empty_string_returns_none():
|
||||
"""Пустая строка → None, без исключений."""
|
||||
assert render_png("") is None
|
||||
|
||||
|
||||
def test_render_png_whitespace_only_returns_none():
|
||||
"""Строка из пробелов → None."""
|
||||
assert render_png(" \n \t ") is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Документация поведения: валидный SVG
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
VALID_ICON_SVG = (
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">'
|
||||
'<circle cx="32" cy="32" r="20" fill="#FF0000"/>'
|
||||
'</svg>'
|
||||
)
|
||||
|
||||
|
||||
def test_render_png_valid_svg_returns_png_or_none():
|
||||
"""Контракт: возвращает либо bytes, либо None.
|
||||
|
||||
Если cairo установлен — bytes (PNG, начинается с magic bytes).
|
||||
Если cairo недоступен — None.
|
||||
В обоих случаях НЕ бросает.
|
||||
"""
|
||||
result = render_png(VALID_ICON_SVG, size=(64, 64))
|
||||
if result is None:
|
||||
# cairo недоступен — это допустимо, см. дизайн-док §6.
|
||||
assert _get_cairosvg() is None, (
|
||||
"_get_cairosvg() вернул модуль, но render_png() вернул None"
|
||||
)
|
||||
else:
|
||||
# cairo есть — должны получить валидный PNG.
|
||||
assert isinstance(result, bytes)
|
||||
assert len(result) > 0
|
||||
# PNG magic: 89 50 4E 47 0D 0A 1A 0A
|
||||
assert result.startswith(b"\x89PNG\r\n\x1a\n"), (
|
||||
f"вывод render_png() не начинается с PNG magic: {result[:16]!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_render_png_uses_size_width_as_output():
|
||||
"""Если cairo доступен, проверяем output_width через прямой вызов."""
|
||||
cairosvg = _get_cairosvg()
|
||||
if cairosvg is None:
|
||||
pytest.skip("cairo недоступен — нельзя проверить output_width")
|
||||
result = render_png(VALID_ICON_SVG, size=(256, 256))
|
||||
assert result is not None
|
||||
# Проверяем, что PNG имеет ширину 256 (viewBox 64x64 → квадрат 256x256)
|
||||
from PIL import Image
|
||||
|
||||
img = Image.open(io.BytesIO(result))
|
||||
assert img.size == (256, 256), f"ожидался 256x256, получено {img.size}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Документация поведения: невалидный SVG
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_render_png_invalid_svg_returns_none_or_raises_cairo():
|
||||
"""Невалидный SVG → либо None, либо cairosvg бросает.
|
||||
|
||||
Наш контракт: render_png() ВСЕГДА возвращает bytes|None, не бросает наружу.
|
||||
Это валидируется в `test_render_png_does_not_propagate_cairo_exceptions`
|
||||
(через mock).
|
||||
"""
|
||||
broken = '<svg xmlns="http://www.w3.org/2000/svg"><circle cx="32" cy="32" r="999999999" fill="oops"/></svg>'
|
||||
# Независимо от состояния cairo, не должны получить необработанный raise наружу.
|
||||
result = render_png(broken, size=(64, 64))
|
||||
assert result is None # либо cairo нет, либо cairo бросил и мы вернули None
|
||||
|
||||
|
||||
def test_render_png_does_not_propagate_cairo_exceptions():
|
||||
"""Даже если cairosvg бросает — render_png() возвращает None, а не raise."""
|
||||
fake_cairosvg = type("Fake", (), {})()
|
||||
# Создаём фейк-модуль, у которого svg2png бросает
|
||||
class FakeCairo:
|
||||
@staticmethod
|
||||
def svg2png(**kwargs):
|
||||
raise RuntimeError("simulated cairo failure")
|
||||
fake = FakeCairo()
|
||||
with patch("renderer._get_cairosvg", return_value=fake):
|
||||
result = render_png(VALID_ICON_SVG, size=(64, 64))
|
||||
assert result is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# xmlns-инъекция
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_render_png_adds_xmlns_if_missing():
|
||||
"""Если модель не поставила xmlns, renderer добавляет его в первую <svg>."""
|
||||
# Прямо проверяем _ensure_xmlns (внутренняя, но контрактная)
|
||||
from renderer import _ensure_xmlns
|
||||
|
||||
no_xmlns = '<svg viewBox="0 0 64 64"><rect/></svg>'
|
||||
fixed = _ensure_xmlns(no_xmlns)
|
||||
assert 'xmlns="http://www.w3.org/2000/svg"' in fixed.split(">", 1)[0]
|
||||
|
||||
already = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"></svg>'
|
||||
assert _ensure_xmlns(already) == already
|
||||
|
||||
# В первом теге xmlns, но в href — не считается (спека смотрит ТОЛЬКО в head)
|
||||
in_attr = '<svg viewBox="0 0 64 64" href="http://x.com"><rect/></svg>'
|
||||
fixed_in_attr = _ensure_xmlns(in_attr)
|
||||
head = fixed_in_attr.split(">", 1)[0]
|
||||
assert 'xmlns="http://www.w3.org/2000/svg"' in head
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# save_png()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_save_png_creates_directory_and_writes_file(tmp_path: Path):
|
||||
"""save_png создаёт каталог и пишет файл с ожидаемым именем."""
|
||||
fake_png = b"\x89PNG\r\n\x1a\n" + b"\x00" * 100 # валидный префикс + мусор
|
||||
previews_dir = tmp_path / "previews"
|
||||
out = save_png(
|
||||
fake_png,
|
||||
previews_dir=previews_dir,
|
||||
record_id=42,
|
||||
candidate_index=0,
|
||||
)
|
||||
assert out.exists()
|
||||
assert out.is_file()
|
||||
assert out == previews_dir / "42_0.png"
|
||||
assert out.read_bytes() == fake_png
|
||||
|
||||
|
||||
def test_save_png_does_not_decode_or_validate_png_bytes(tmp_path: Path):
|
||||
"""save_png пишет байты as-is — никаких проверок PNG-формата."""
|
||||
previews_dir = tmp_path / "p"
|
||||
# Мусорные байты — не PNG, но save_png это не волнует.
|
||||
out = save_png(
|
||||
b"not actually png",
|
||||
previews_dir=previews_dir,
|
||||
record_id=1,
|
||||
candidate_index=3,
|
||||
)
|
||||
assert out == previews_dir / "1_3.png"
|
||||
assert out.read_bytes() == b"not actually png"
|
||||
|
||||
|
||||
def test_save_png_appends_filename_with_zero_padded_index(tmp_path: Path):
|
||||
"""record_id=5, candidate_index=7 → '5_7.png'."""
|
||||
previews_dir = tmp_path / "p"
|
||||
out = save_png(b"x", previews_dir=previews_dir, record_id=5, candidate_index=7)
|
||||
assert out.name == "5_7.png"
|
||||
|
||||
|
||||
def test_save_png_overwrites_existing_file(tmp_path: Path):
|
||||
"""Повторный save с тем же (record_id, candidate_index) перезаписывает."""
|
||||
previews_dir = tmp_path / "p"
|
||||
out1 = save_png(b"first", previews_dir=previews_dir, record_id=1, candidate_index=0)
|
||||
out2 = save_png(b"second", previews_dir=previews_dir, record_id=1, candidate_index=0)
|
||||
assert out1 == out2
|
||||
assert out1.read_bytes() == b"second"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Интеграция: render + save (если cairo есть)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_render_and_save_integration(tmp_path: Path):
|
||||
"""Сквозной кейс: render → save → файл существует и непустой."""
|
||||
result = render_png(VALID_ICON_SVG, size=(64, 64))
|
||||
if result is None:
|
||||
pytest.skip("cairo недоступен — интеграционный тест render+save пропущен")
|
||||
out = save_png(
|
||||
result,
|
||||
previews_dir=tmp_path / "previews",
|
||||
record_id=10,
|
||||
candidate_index=0,
|
||||
)
|
||||
assert out.exists()
|
||||
assert out.stat().st_size > 0
|
||||
# Содержимое — валидный PNG
|
||||
from PIL import Image
|
||||
|
||||
img = Image.open(out)
|
||||
img.verify() # поднимает, если битый PNG
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Граничный случай: путь с пробелами / Unicode
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_save_png_works_with_unicode_in_path(tmp_path: Path):
|
||||
"""save_png принимает каталог с юникодом (актуально для Windows)."""
|
||||
previews_dir = tmp_path / "превью" # кириллица
|
||||
out = save_png(b"x", previews_dir=previews_dir, record_id=1, candidate_index=0)
|
||||
assert out.exists()
|
||||
assert out.name == "1_0.png"
|
||||
@@ -0,0 +1,213 @@
|
||||
"""Юнит-тесты для validator.py.
|
||||
|
||||
Покрывают контрактные правила из design.md §5 и явные требования задачи:
|
||||
- валидный SVG → ok=True
|
||||
- отсутствует viewBox → ok=False (missing_viewbox)
|
||||
- есть <script> → ok=False (disallowed_tag)
|
||||
- есть <foreignObject> → ok=False (disallowed_tag)
|
||||
- http:// ссылка в href → ok=False (external_ref)
|
||||
- on*= атрибут → ok=False (event_handler)
|
||||
|
||||
Плюс несколько дополнительных кейсов, чтобы покрыть size-limit, парсинг
|
||||
из обёртки с пояснением и edge cases.
|
||||
|
||||
Запуск:
|
||||
python -m pytest tests/ -v
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Делаем корень проекта доступным как `import validator`.
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
import pytest # noqa: E402
|
||||
|
||||
from validator import ( # noqa: E402
|
||||
MAX_BYTES,
|
||||
validate,
|
||||
validate_svg,
|
||||
extract_svg,
|
||||
ValidatorError,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Положительные кейсы
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
VALID_ICON = """<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
|
||||
<circle cx="26" cy="26" r="14" fill="#3B82F6"/>
|
||||
<rect x="36" y="34" width="6" height="20" rx="3" fill="#3B82F6" transform="rotate(45 39 44)"/>
|
||||
</svg>"""
|
||||
|
||||
|
||||
def test_valid_svg_returns_ok_and_cleaned():
|
||||
ok, reason, cleaned = validate_svg(VALID_ICON, mode="icon")
|
||||
assert ok is True
|
||||
assert reason == ""
|
||||
assert "<svg" in cleaned
|
||||
assert cleaned.endswith("</svg>")
|
||||
# viewBox должен сохраниться после сериализации.
|
||||
assert 'viewBox="0 0 64 64"' in cleaned
|
||||
|
||||
|
||||
def test_valid_svg_wrapped_in_prose_is_extracted():
|
||||
"""Модель иногда отвечает `Here is your icon: <svg>...</svg>`. Должны вытащить."""
|
||||
wrapped = (
|
||||
"Sure! Here is your SVG icon:\n"
|
||||
f"{VALID_ICON}\n"
|
||||
"Hope that helps."
|
||||
)
|
||||
ok, reason, cleaned = validate_svg(wrapped, mode="icon")
|
||||
assert ok is True, f"expected ok, got reason={reason!r}"
|
||||
assert reason == ""
|
||||
assert "<svg" in cleaned
|
||||
|
||||
|
||||
def test_valid_illustration_with_filter():
|
||||
"""В illustration разрешены <filter>, <feGaussianBlur> и т.п."""
|
||||
ill = """<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
||||
<defs>
|
||||
<filter id="b" x="-10%" y="-10%" width="120%" height="120%">
|
||||
<feGaussianBlur stdDeviation="2"/>
|
||||
</filter>
|
||||
</defs>
|
||||
<rect x="100" y="100" width="200" height="200" fill="#ff0000" filter="url(#b)"/>
|
||||
</svg>"""
|
||||
ok, reason, _ = validate_svg(ill, mode="illustration")
|
||||
assert ok is True, f"reason={reason!r}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Негативные кейсы
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_missing_viewbox_rejected():
|
||||
bad = """<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="10" cy="10" r="5"/>
|
||||
</svg>"""
|
||||
ok, reason, cleaned = validate_svg(bad, mode="icon")
|
||||
assert ok is False
|
||||
assert reason == "missing_viewbox"
|
||||
assert cleaned == ""
|
||||
|
||||
|
||||
def test_bad_viewbox_rejected():
|
||||
bad = """<svg xmlns="http://www.w3.org/2000/svg" viewBox="oops">
|
||||
<circle cx="10" cy="10" r="5"/>
|
||||
</svg>"""
|
||||
ok, reason, _ = validate_svg(bad, mode="icon")
|
||||
assert ok is False
|
||||
assert reason == "bad_viewbox"
|
||||
|
||||
|
||||
def test_script_tag_rejected():
|
||||
bad = """<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<script>alert(1)</script>
|
||||
</svg>"""
|
||||
ok, reason, _ = validate_svg(bad, mode="icon")
|
||||
assert ok is False
|
||||
assert reason == "disallowed_tag"
|
||||
|
||||
|
||||
def test_foreign_object_rejected():
|
||||
bad = """<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<foreignObject width="100" height="100">
|
||||
<div xmlns="http://www.w3.org/1999/xhtml">Hi</div>
|
||||
</foreignObject>
|
||||
</svg>"""
|
||||
ok, reason, _ = validate_svg(bad, mode="icon")
|
||||
assert ok is False
|
||||
assert reason == "disallowed_tag"
|
||||
|
||||
|
||||
def test_http_ref_rejected():
|
||||
bad = """<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<use href="http://example.com/sprite.svg#icon"/>
|
||||
</svg>"""
|
||||
ok, reason, _ = validate_svg(bad, mode="icon")
|
||||
assert ok is False
|
||||
assert reason == "external_ref"
|
||||
|
||||
|
||||
def test_onclick_attribute_rejected():
|
||||
bad = """<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<rect x="0" y="0" width="10" height="10" onclick="alert(1)"/>
|
||||
</svg>"""
|
||||
ok, reason, _ = validate_svg(bad, mode="icon")
|
||||
assert ok is False
|
||||
assert reason == "event_handler"
|
||||
|
||||
|
||||
def test_url_with_https_rejected():
|
||||
"""https:// в url(...) тоже блокируется."""
|
||||
bad = """<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<defs>
|
||||
<pattern id="p" width="4" height="4" patternUnits="userSpaceOnUse">
|
||||
<rect width="4" height="4" fill="url('https://example.com/tex.png')"/>
|
||||
</pattern>
|
||||
</defs>
|
||||
</svg>"""
|
||||
ok, reason, _ = validate_svg(bad, mode="icon")
|
||||
assert ok is False
|
||||
# Сейчас external_ref ловится на `url(` независимо от http(s).
|
||||
assert reason in ("external_ref", "disallowed_tag", "unknown_tag")
|
||||
|
||||
|
||||
def test_too_large_rejected():
|
||||
"""SVG > MAX_BYTES[mode] → too_large."""
|
||||
# Соберём раздутый SVG с большим комментарием.
|
||||
pad = "<!-- " + ("x" * (MAX_BYTES["icon"] + 100)) + " -->"
|
||||
bad = (
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">'
|
||||
+ pad
|
||||
+ "</svg>"
|
||||
)
|
||||
ok, reason, _ = validate_svg(bad, mode="icon")
|
||||
assert ok is False
|
||||
assert reason == "too_large"
|
||||
|
||||
|
||||
def test_empty_input_rejected():
|
||||
ok, reason, _ = validate_svg("", mode="icon")
|
||||
assert ok is False
|
||||
assert reason == "empty_input"
|
||||
|
||||
|
||||
def test_no_svg_block_rejected():
|
||||
ok, reason, _ = validate_svg("Sorry, I cannot help with that.", mode="icon")
|
||||
assert ok is False
|
||||
assert reason == "not_svg"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Прямые проверки API (validate() бросает, extract_svg() утилитарный)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_validate_strict_raises():
|
||||
with pytest.raises(ValidatorError) as excinfo:
|
||||
validate("plain text", mode="icon")
|
||||
assert excinfo.value.code == "not_svg"
|
||||
|
||||
|
||||
def test_extract_svg_finds_block():
|
||||
text = "noise <svg viewBox='0 0 1 1'></svg> noise"
|
||||
found = extract_svg(text)
|
||||
assert found.startswith("<svg")
|
||||
assert found.endswith("</svg>")
|
||||
|
||||
|
||||
def test_validate_svg_returns_tuple_for_unexpected_exception():
|
||||
"""Даже если что-то странное, обёртка возвращает кортеж (ok=False, ...)."""
|
||||
ok, reason, cleaned = validate_svg(None, mode="icon") # type: ignore[arg-type]
|
||||
assert ok is False
|
||||
assert reason in ("empty_input", "internal:TypeError")
|
||||
assert cleaned == ""
|
||||
Reference in New Issue
Block a user