"""Юнит-тесты для 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 без (по дизайну).""" msgs = load_few_shot() # Ищем любой assistant-блок, который не содержит refusals = [m for m in msgs if m["role"] == "assistant" and "= 1, "few-shot должен включать хотя бы один refusal" def test_load_few_shot_includes_svg_examples(): """Среди assistant'ов есть и реальные ....""" msgs = load_few_shot() svg_assistants = [m for m in msgs if m["role"] == "assistant" and "= 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"] )