Initial commit: OmniSVG-Lite MVP before live-streaming work
- LM Studio client (httpx-based, OpenAI-compatible) - SVG validator (lxml, whitelist tags, no <script>/<foreignObject>/http refs) - PNG renderer (resvg-py primary, cairosvg fallback - no native cairo dep) - History (SQLite, tracks raw/validated/preview paths) - Gradio UI on 127.0.0.1:8788 with: * mode radio (icon/illustration) * n_candidates slider (default 1) * image upload for image-to-SVG * LM Studio URL/token inputs * model dropdown + refresh button - prompts/ with system_icon.txt, system_illustration.txt, few_shot_examples.txt - docs/spec.md, docs/design.md - 122 unit/integration tests passing
This commit is contained in:
@@ -0,0 +1,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"
|
||||
Reference in New Issue
Block a user