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:
Mavis
2026-06-13 15:32:54 +03:00
commit 2394eff1c0
21 changed files with 5116 additions and 0 deletions
+240
View File
@@ -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
+404
View File
@@ -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"
+651
View File
@@ -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"
+345
View File
@@ -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"]
)
+248
View File
@@ -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"
+213
View File
@@ -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 == ""