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
+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"