Files
omnisvg-lite/tests/test_lm_client.py
Mavis 2394eff1c0 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
2026-06-13 15:32:54 +03:00

652 lines
24 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Юнит-тесты для 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"