Files
omnisvg-lite/tests/test_prompts.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

346 lines
14 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.
"""Юнит-тесты для 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"]
)