Files
omnisvg-lite/scripts/smoke_test.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

320 lines
11 KiB
Python

"""Smoke-test реального пути данных через локальный mock-LM-Server.
Поднимает HTTP-сервер на 127.0.0.1:<port> (по умолчанию 1234), который
принимает POST /v1/chat/completions и возвращает фиксированный валидный
SVG-ответ в формате OpenAI chat completions. Затем:
1) гоняет lm_client.generate_svg() через этот mock;
2) валидирует результат через validator.validate_svg();
3) рендерит PNG через renderer.render_png() (если cairo есть);
4) пишет запись в history через History() (SQLite в tmp).
Печатает PASS или FAIL с подробностями. Код выхода:
0 — PASS
1 — FAIL
Запуск:
python scripts/smoke_test.py
Переменные окружения (опц.):
SMOKE_PORT — порт mock-сервера (default 1234; будет увеличен, если занят)
OMNISVG_DB_PATH — путь к SQLite (default в tmp)
OMNISVG_PREVIEW_DIR — каталог превью (default в tmp)
ВАЖНО: скрипт НЕ зависит от запущенного LM Studio. Это именно mock-сервер,
который имитирует LM Studio достаточно, чтобы пройти через весь pipeline.
"""
from __future__ import annotations
import json
import os
import socket
import sys
import tempfile
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
# Добавляем корень проекта в sys.path
ROOT = Path(__file__).resolve().parent.parent
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
# ---------------------------------------------------------------------------
# Mock LM Studio
# ---------------------------------------------------------------------------
# Валидный SVG, который mock всегда возвращает. Иконка 64x64, простая.
FIXTURE_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="#3B82F6"/>'
'<rect x="36" y="36" width="6" height="20" rx="3" fill="#3B82F6" '
'transform="rotate(45 39 46)"/>'
'</svg>'
)
class MockLMHandler(BaseHTTPRequestHandler):
"""Отвечает 200 OK + OpenAI-style JSON на /v1/chat/completions.
Возвращает ровно `n` choice'ов (из тела запроса), все с FIXTURE_SVG.
Если `n` не прислали — 1.
"""
def log_message(self, format, *args): # noqa: A002 — шумно молчим
return # без stdout-спама
def do_POST(self): # noqa: N802 — http.server API
length = int(self.headers.get("Content-Length", "0") or "0")
body_bytes = self.rfile.read(length) if length > 0 else b""
if self.path.endswith("/chat/completions") or self.path == "/v1/chat/completions":
# Попробуем достать n из payload (иначе 1)
n = 1
try:
req = json.loads(body_bytes.decode("utf-8"))
n = int(req.get("n", 1))
if n < 1:
n = 1
except (ValueError, json.JSONDecodeError):
pass
choices = [
{
"index": i,
"message": {"role": "assistant", "content": FIXTURE_SVG},
"finish_reason": "stop",
}
for i in range(n)
]
payload = {
"id": "chatcmpl-smoke",
"object": "chat.completion",
"created": int(time.time()),
"model": "smoke-mock",
"choices": choices,
"usage": {
"prompt_tokens": 10,
"completion_tokens": 5 * n,
"total_tokens": 10 + 5 * n,
},
}
body = json.dumps(payload).encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
return
# Любой другой путь — 404
self.send_response(404)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(b'{"error":"not found"}')
def _find_free_port(preferred: int) -> int:
"""Возвращает preferred, если свободен; иначе 0 (= ОС выберет)."""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
try:
s.bind(("127.0.0.1", preferred))
return preferred
except OSError:
return 0
def _start_mock_server(port: int) -> tuple[ThreadingHTTPServer, int]:
"""Поднимает mock-LM и возвращает (server, actual_port)."""
server = ThreadingHTTPServer(("127.0.0.1", port), MockLMHandler)
actual_port = server.server_address[1]
t = threading.Thread(target=server.serve_forever, name="mock-lm", daemon=True)
t.start()
return server, actual_port
# ---------------------------------------------------------------------------
# Smoke-логика
# ---------------------------------------------------------------------------
def _check(step: str, cond: bool, detail: str = "") -> bool:
"""Печатает шаг и его результат. Возвращает cond для chaining."""
status = "OK" if cond else "FAIL"
print(f" [{status}] {step}" + (f"{detail}" if detail else ""))
return cond
def main() -> int:
print("=" * 60)
print("OmniSVG-Lite smoke test (mock LM Studio)")
print("=" * 60)
# tmp-каталоги для изоляции
tmpdir = Path(tempfile.mkdtemp(prefix="omnisvg_smoke_"))
db_path = tmpdir / "smoke.sqlite"
previews_dir = tmpdir / "previews"
previews_dir.mkdir(parents=True, exist_ok=True)
# env-инжекция, чтобы lm_client/History/app пошли в наши пути
os.environ["OMNISVG_DB_PATH"] = str(db_path)
os.environ["OMNISVG_PREVIEW_DIR"] = str(previews_dir)
# Поднимаем mock-сервер
preferred = int(os.environ.get("SMOKE_PORT", "1234"))
port = _find_free_port(preferred)
server, actual_port = _start_mock_server(port)
base_url = f"http://127.0.0.1:{actual_port}/v1"
print(f" mock server up at {base_url} (preferred port={preferred})")
all_ok = True
all_ok &= _check("mock server started", server is not None)
# Дать серверу подняться
time.sleep(0.05)
results: list[dict] = []
validated_svgs: list[str] = []
previews: list[str] = []
# 1) LM client: generate_svg()
try:
from lm_client import generate_svg, chat
# Сначала сходим напрямую через chat() — чтобы видеть transport
result = chat(
messages=[{"role": "user", "content": "a fox"}],
n=1,
temperature=0.4,
base_url=base_url,
timeout_s=5.0,
)
all_ok &= _check(
"chat() returned LMTurnResult",
len(result.raw_texts) == 1,
f"raw_texts={result.raw_texts}",
)
all_ok &= _check(
"chat() payload contains expected SVG",
FIXTURE_SVG in result.raw_texts[0],
f"first 60 chars: {result.raw_texts[0][:60]!r}",
)
# Теперь — высокоуровневый generate_svg()
results = generate_svg(
"a fox",
n=2,
temperature=0.4,
model="smoke-mock",
system_prompt="<system>icon</system>",
)
all_ok &= _check(
"generate_svg() returned 2 candidates",
len(results) == 2,
f"got {len(results)}",
)
all_ok &= _check(
"each candidate has 'svg_text' and 'finish_reason'",
all("svg_text" in r and "finish_reason" in r for r in results),
)
except Exception as exc: # noqa: BLE001
all_ok = False
print(f" [FAIL] lm_client raised: {type(exc).__name__}: {exc}")
# 2) Validator
try:
from validator import validate_svg
for r in results:
ok, reason, cleaned = validate_svg(r["svg_text"], mode="icon")
all_ok &= _check(
f"validate_svg(icon) ok={ok}",
ok,
f"reason={reason!r}, cleaned_len={len(cleaned)}",
)
if ok:
validated_svgs.append(cleaned)
except Exception as exc: # noqa: BLE001
all_ok = False
print(f" [FAIL] validator raised: {type(exc).__name__}: {exc}")
# 3) Renderer (graceful, если cairo недоступен)
try:
from renderer import render_png, save_png
for i, svg in enumerate(validated_svgs):
png = render_png(svg, size=(64, 64))
if png is None:
# cairo недоступен — это допустимо, просто отметим.
print(f" [SKIP] render_png(#{i}) → None (cairo unavailable)")
continue
path = save_png(
png,
previews_dir=previews_dir,
record_id=0,
candidate_index=i,
)
previews.append(str(path))
all_ok &= _check(
f"render_png(#{i}) → {path.name}",
path.exists() and path.stat().st_size > 0,
)
except Exception as exc: # noqa: BLE001
all_ok = False
print(f" [FAIL] renderer raised: {type(exc).__name__}: {exc}")
# 4) History
try:
from history import History, Record
with History(db_path) as h:
record_id = h.add(
Record(
prompt="a fox",
mode="icon",
model="smoke-mock",
n_requested=2,
n_returned=len(results),
temperature=0.4,
status="ok" if validated_svgs else "all_invalid",
error_reason=None,
raw_outputs=[r["raw"] for r in results],
validated_outputs=validated_svgs,
previews=previews,
best_index=0 if validated_svgs else None,
)
)
all_ok &= _check(
"History.add() returned id",
isinstance(record_id, int) and record_id > 0,
f"id={record_id}",
)
with History(db_path) as h:
rec = h.get(record_id)
all_ok &= _check(
"History.get() roundtrip",
rec is not None and rec["prompt"] == "a fox",
f"prompt={rec['prompt'] if rec else None!r}",
)
except Exception as exc: # noqa: BLE001
all_ok = False
print(f" [FAIL] history raised: {type(exc).__name__}: {exc}")
# 5) Cleanup
server.shutdown()
server.server_close()
print("=" * 60)
if all_ok:
print("PASS — все шаги успешны")
return 0
print("FAIL — см. подробности выше")
return 1
if __name__ == "__main__":
sys.exit(main())