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