Spaces:
Running
Running
Merge agent/trim: drop dead code and merge duplicate tests
Browse files- lumen_commands.py +0 -5
- lumen_message_parse.py +0 -22
- lumen_router_config.py +1 -1
- lumen_routes.py +0 -2
- lumen_security.py +0 -8
- tests/test_bot_commands.py +83 -129
- tests/test_bot_routes.py +84 -231
- tests/test_bot_state.py +58 -95
- tests/test_bot_tiktok.py +105 -198
- tests/test_bot_transport.py +21 -40
- tests/test_lumen_formatting.py +133 -174
- tests/test_lumen_images.py +22 -39
lumen_commands.py
CHANGED
|
@@ -1006,11 +1006,6 @@ async def handle_pick_callback(query: CallbackQuery) -> None:
|
|
| 1006 |
with contextlib.suppress(Exception):
|
| 1007 |
await query.answer()
|
| 1008 |
return
|
| 1009 |
-
if not _is_rec_owner(rec):
|
| 1010 |
-
# Та же проверка авторства, что выше до перевыпуска (дубль убран, поведение то же).
|
| 1011 |
-
with contextlib.suppress(Exception):
|
| 1012 |
-
await query.answer(_lang_t(rec_lang, "pick_not_yours"), show_alert=True)
|
| 1013 |
-
return
|
| 1014 |
# Все проверки пройдены — только теперь забираем токен (см. комментарий у get выше).
|
| 1015 |
# Без сообщения кнопки не во что упереть: токен не трогаем, иначе тап из инлайн
|
| 1016 |
# режима сжёг бы чужой выбор без дела.
|
|
|
|
| 1006 |
with contextlib.suppress(Exception):
|
| 1007 |
await query.answer()
|
| 1008 |
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1009 |
# Все проверки пройдены — только теперь забираем токен (см. комментарий у get выше).
|
| 1010 |
# Без сообщения кнопки не во что упереть: токен не трогаем, иначе тап из инлайн
|
| 1011 |
# режима сжёг бы чужой выбор без дела.
|
lumen_message_parse.py
CHANGED
|
@@ -181,28 +181,6 @@ _PICK_EXACT_TOPICS: dict[str, str] = {
|
|
| 181 |
"игре": "games", "игр": "games", "играть": "games", "поиграть": "games",
|
| 182 |
}
|
| 183 |
_PICK_MAX_LEN = 60
|
| 184 |
-
PICK_QUESTIONS: dict[str, str] = {
|
| 185 |
-
"film": "Что сегодня хочется?",
|
| 186 |
-
"series": "Что сегодня хочется?",
|
| 187 |
-
"music": "Какое настроение?",
|
| 188 |
-
"books": "Что сегодня хочется?",
|
| 189 |
-
"games": "Во что хочется?",
|
| 190 |
-
}
|
| 191 |
-
PICK_OPTIONS: dict[str, list[str]] = {
|
| 192 |
-
"film": ["Лёгкое и весёлое", "Драма", "Триллер", "Фантастика"],
|
| 193 |
-
"series": ["Лёгкое и весёлое", "Драма", "Детектив", "Фантастика"],
|
| 194 |
-
"music": ["Энергичное", "Спокойное", "Грустное", "Весёлое"],
|
| 195 |
-
"books": ["Фантастика", "Детектив", "Нон-фикшн", "Классика"],
|
| 196 |
-
"games": ["Экшен", "Стратегия", "RPG", "Головоломка"],
|
| 197 |
-
}
|
| 198 |
-
# Как выбор дописывается к исходному запросу перед обычным маршрутом.
|
| 199 |
-
PICK_CHOICE_TEMPLATES: dict[str, str] = {
|
| 200 |
-
"film": "{original} (жанр: {choice})",
|
| 201 |
-
"series": "{original} (жанр: {choice})",
|
| 202 |
-
"music": "{original} (настроение: {choice})",
|
| 203 |
-
"books": "{original} (жанр: {choice})",
|
| 204 |
-
"games": "{original} (жанр: {choice})",
|
| 205 |
-
}
|
| 206 |
|
| 207 |
def match_pick_request(text_lower: str) -> str | None:
|
| 208 |
"""Возвращает id pick-сценария (film/music/...), если текст — вкусовой
|
|
|
|
| 181 |
"игре": "games", "игр": "games", "играть": "games", "поиграть": "games",
|
| 182 |
}
|
| 183 |
_PICK_MAX_LEN = 60
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 184 |
|
| 185 |
def match_pick_request(text_lower: str) -> str | None:
|
| 186 |
"""Возвращает id pick-сценария (film/music/...), если текст — вкусовой
|
lumen_router_config.py
CHANGED
|
@@ -354,7 +354,7 @@ def _gemini_route(models: list[str]) -> list[tuple[str, str]]:
|
|
| 354 |
|
| 355 |
# ── Groq (прямой провайдер, не через OpenRouter) ──
|
| 356 |
# Калибровка живьём 21.09.2026: Qwen голова (чисто и по делу), gpt-oss второй (представляется ChatGPT и тратит reasoning-токены). Лимиты free: 30 RPM / 1000 RPD / 8K TPM / 200K TPD.
|
| 357 |
-
|
| 358 |
_GROQ_LIGHT_ORDER: list[str] = [
|
| 359 |
"qwen/qwen3.8-27b",
|
| 360 |
"openai/gpt-oss-120b",
|
|
|
|
| 354 |
|
| 355 |
# ── Groq (прямой провайдер, не через OpenRouter) ──
|
| 356 |
# Калибровка живьём 21.09.2026: Qwen голова (чисто и по делу), gpt-oss второй (представляется ChatGPT и тратит reasoning-токены). Лимиты free: 30 RPM / 1000 RPD / 8K TPM / 200K TPD.
|
| 357 |
+
# Базовый URL один: bot.GROQ_BASE_URL, дубля здесь нет (аудит 05.10.2026).
|
| 358 |
_GROQ_LIGHT_ORDER: list[str] = [
|
| 359 |
"qwen/qwen3.8-27b",
|
| 360 |
"openai/gpt-oss-120b",
|
lumen_routes.py
CHANGED
|
@@ -738,8 +738,6 @@ async def ask_gemini(
|
|
| 738 |
import bot
|
| 739 |
state = bot.get_state(chat_id)
|
| 740 |
chain = list(model_chain) if model_chain else list(GEMINI_DEFAULT_CHAIN)
|
| 741 |
-
if not chain:
|
| 742 |
-
chain = list(GEMINI_DEFAULT_CHAIN)
|
| 743 |
if deadline is None:
|
| 744 |
deadline = time.monotonic() + bot.ROUTE_TOTAL_BUDGET_SEC
|
| 745 |
hist = state.setdefault("history", [])
|
|
|
|
| 738 |
import bot
|
| 739 |
state = bot.get_state(chat_id)
|
| 740 |
chain = list(model_chain) if model_chain else list(GEMINI_DEFAULT_CHAIN)
|
|
|
|
|
|
|
| 741 |
if deadline is None:
|
| 742 |
deadline = time.monotonic() + bot.ROUTE_TOTAL_BUDGET_SEC
|
| 743 |
hist = state.setdefault("history", [])
|
lumen_security.py
CHANGED
|
@@ -221,14 +221,6 @@ _INJECTION_PROBE_RE = re.compile(
|
|
| 221 |
re.IGNORECASE,
|
| 222 |
)
|
| 223 |
|
| 224 |
-
def _normalize_for_detection(text: str) -> str:
|
| 225 |
-
# Обход через невидимки/совместимые символы: чистим копию для проверки.
|
| 226 |
-
if not text:
|
| 227 |
-
return text
|
| 228 |
-
norm = unicodedata.normalize("NFKC", text)
|
| 229 |
-
return "".join(ch for ch in norm if unicodedata.category(ch) != "Cf")
|
| 230 |
-
|
| 231 |
-
|
| 232 |
def _looks_like_injection_probe(text: str) -> bool:
|
| 233 |
"""Чистая функция — тестируется отдельно от _handle_message_core."""
|
| 234 |
if not text:
|
|
|
|
| 221 |
re.IGNORECASE,
|
| 222 |
)
|
| 223 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 224 |
def _looks_like_injection_probe(text: str) -> bool:
|
| 225 |
"""Чистая функция — тестируется отдельно от _handle_message_core."""
|
| 226 |
if not text:
|
tests/test_bot_commands.py
CHANGED
|
@@ -310,50 +310,51 @@ def test_build_version_caches_git_call(monkeypatch):
|
|
| 310 |
monkeypatch.setattr(lc, "_BUILD_VERSION_CACHED", None)
|
| 311 |
|
| 312 |
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
#
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
#
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
|
|
|
| 357 |
|
| 358 |
|
| 359 |
def test_strip_reply_marker_treats_demonstratives_as_empty():
|
|
@@ -370,32 +371,6 @@ def test_strip_reply_marker_treats_demonstratives_as_empty():
|
|
| 370 |
assert bot._strip_reply_marker("этот текст, пожалуйста") == "этот текст, пожалуйста"
|
| 371 |
|
| 372 |
|
| 373 |
-
def test_match_trigger_prefix_requires_word_boundary_and_polite_forms():
|
| 374 |
-
# Найдено код-ревью: чистый startswith без границы слова давал мусор —
|
| 375 |
-
# "прочтите" начиналось с "прочти" (остаток "те..."), "нарисуйка" — с
|
| 376 |
-
# "нарисуй". Теперь после префикса нужны конец строки/пробел/пунктуация.
|
| 377 |
-
assert bot._match_trigger_prefix("нарисуйка", bot.DRAW_TRIGGER_PREFIXES) is None
|
| 378 |
-
assert bot._match_trigger_prefix("прочтите этот текст", bot.TTS_TRIGGER_PREFIXES) == "прочтите"
|
| 379 |
-
assert bot._match_trigger_prefix("прочтите вслух сказку", bot.TTS_TRIGGER_PREFIXES) == "прочтите вслух"
|
| 380 |
-
assert bot._match_trigger_prefix("озвучьте текст", bot.TTS_TRIGGER_PREFIXES) == "озвучьте"
|
| 381 |
-
assert bot._match_trigger_prefix("нарисуйте кота", bot.DRAW_TRIGGER_PREFIXES) == "нарисуйте"
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
def test_match_trigger_prefix_english_triggers():
|
| 385 |
-
# Внешний аудит: /start обещает "draw a cat"/"read this out loud", а триггеры были только русские.
|
| 386 |
-
assert bot._match_trigger_prefix("draw a cat on the beach", bot.DRAW_TRIGGER_PREFIXES) == "draw a"
|
| 387 |
-
assert bot._match_trigger_prefix("generate an image of a castle", bot.DRAW_TRIGGER_PREFIXES) == "generate an image"
|
| 388 |
-
assert bot._match_trigger_prefix("read this out loud please", bot.TTS_TRIGGER_PREFIXES) == "read this out loud"
|
| 389 |
-
assert bot._match_trigger_prefix("voice this message", bot.TTS_TRIGGER_PREFIXES) == "voice this"
|
| 390 |
-
assert bot._match_trigger_prefix("how are you", bot.DRAW_TRIGGER_PREFIXES) is None
|
| 391 |
-
assert bot._match_trigger_prefix("how are you", bot.TTS_TRIGGER_PREFIXES) is None
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
def test_match_trigger_prefix_rejects_draw_idiom():
|
| 395 |
-
# Ревью ветки: "draw some" ловил идиому "draw conclusions".
|
| 396 |
-
assert bot._match_trigger_prefix("draw some conclusions here", bot.DRAW_TRIGGER_PREFIXES) is None
|
| 397 |
-
|
| 398 |
-
|
| 399 |
def test_tts_trigger_this_with_reply_voices_replied_message(rate_guard_setup):
|
| 400 |
# Регрессия (сентябрь 2026): "озвучь это" в ответ на сообщение озвучивало
|
| 401 |
# само слово "это" — остаток после триггера считался содержанием.
|
|
@@ -478,34 +453,21 @@ def test_draw_trigger_this_with_reply_draws_replied_message(rate_guard_setup):
|
|
| 478 |
bot.inline_draw.assert_awaited_once_with(message, "закат над морем")
|
| 479 |
|
| 480 |
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
def test_pick_image_model_detects_quick_draft():
|
| 497 |
-
assert bot._pick_image_model("быстрый набросок логотипа") == "turbo"
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
def test_pick_image_model_falls_back_to_default_for_generic_prompt():
|
| 501 |
-
assert bot._pick_image_model("космическая станция на орбите Земли") == bot.DEFAULT_POLLINATIONS_IMAGE_MODEL
|
| 502 |
-
assert bot._pick_image_model("") == bot.DEFAULT_POLLINATIONS_IMAGE_MODEL
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
def test_pick_image_model_style_keyword_wins_over_quick_keyword():
|
| 506 |
-
# Стилевой сигнал важнее просьбы "побыстрее", если оба есть в одном промпте —
|
| 507 |
-
# см. докстринг _pick_image_model про порядок проверок.
|
| 508 |
-
assert bot._pick_image_model("быстро нарисуй аниме-девушку") == "flux-anime"
|
| 509 |
|
| 510 |
|
| 511 |
def test_imgmodel_command_and_callback_removed():
|
|
@@ -786,40 +748,32 @@ def test_natural_language_trigger_consumes_one_slot(rate_guard_setup, text, hand
|
|
| 786 |
assert len(lumen_limits.user_rate_limits[456]) == 1
|
| 787 |
|
| 788 |
|
| 789 |
-
|
| 790 |
-
|
| 791 |
-
|
| 792 |
-
|
| 793 |
-
|
| 794 |
-
|
| 795 |
# Английские запросы — тот же детектор (дефолтный язык бота — en).
|
| 796 |
-
|
| 797 |
-
|
| 798 |
-
|
| 799 |
-
|
| 800 |
-
|
| 801 |
# Длинный запрос с деталями — обычным путём в модель, без кнопок.
|
| 802 |
-
|
| 803 |
-
|
| 804 |
-
|
| 805 |
-
|
| 806 |
# Латиница — только по границам слов: "notebook" — не книги.
|
| 807 |
-
|
| 808 |
-
# Кириллица — по началу слова: "тигр" — не игры (AUD-E-006)
|
| 809 |
-
|
| 810 |
-
|
| 811 |
-
|
| 812 |
-
|
| 813 |
-
def
|
| 814 |
-
|
| 815 |
-
assert bot.match_pick_request("посоветуй кто играет сегодня") is None
|
| 816 |
-
|
| 817 |
-
|
| 818 |
-
def test_match_pick_request_accepts_game_nouns_and_infinitives():
|
| 819 |
-
# Сторож: существительные и инфинитивы по-прежнему ведут в games.
|
| 820 |
-
assert bot.match_pick_request("придумай игру для компании") == "games"
|
| 821 |
-
assert bot.match_pick_request("посоветуй игры") == "games"
|
| 822 |
-
assert bot.match_pick_request("посоветуй во что поиграть") == "games"
|
| 823 |
|
| 824 |
|
| 825 |
def test_pick_question_sent_instead_of_ai_route(rate_guard_setup, monkeypatch):
|
|
|
|
| 310 |
monkeypatch.setattr(lc, "_BUILD_VERSION_CACHED", None)
|
| 311 |
|
| 312 |
|
| 313 |
+
@pytest.mark.parametrize(("text", "prefixes", "expected"), [
|
| 314 |
+
("нарисуй кота на пляже", bot.DRAW_TRIGGER_PREFIXES, "нарисуй"),
|
| 315 |
+
("озвучь этот текст пожалуйста", bot.TTS_TRIGGER_PREFIXES, "озвучь"),
|
| 316 |
+
("преврати в аудио вот это сообщение", bot.TTS_TRIGGER_PREFIXES, "преврати в аудио"),
|
| 317 |
+
("сгенери картинку заката", bot.DRAW_TRIGGER_PREFIXES, "сгенери картинку"),
|
| 318 |
+
# Разговорные синонимы (сентябрь 2026).
|
| 319 |
+
("зачитай этот абзац", bot.TTS_TRIGGER_PREFIXES, "зачитай"),
|
| 320 |
+
("зачитай текст договора", bot.TTS_TRIGGER_PREFIXES, "зачитай текст"),
|
| 321 |
+
("прочти вслух мою историю", bot.TTS_TRIGGER_PREFIXES, "прочти вслух"),
|
| 322 |
+
("сгенерируй мне изображение замка", bot.DRAW_TRIGGER_PREFIXES, "сгенерируй мне изображение"),
|
| 323 |
+
("создай мне картинку с котом", bot.DRAW_TRIGGER_PREFIXES, "создай мне картинку"),
|
| 324 |
+
# Длинная фраза побеждает короткий префикс (порядок спи��ка).
|
| 325 |
+
("прочти вслух", bot.TTS_TRIGGER_PREFIXES, "прочти вслух"),
|
| 326 |
+
# Граница слова и вежливые формы.
|
| 327 |
+
("прочтите этот текст", bot.TTS_TRIGGER_PREFIXES, "прочтите"),
|
| 328 |
+
("прочтите вслух сказку", bot.TTS_TRIGGER_PREFIXES, "прочтите вслух"),
|
| 329 |
+
("озвучьте текст", bot.TTS_TRIGGER_PREFIXES, "озвучьте"),
|
| 330 |
+
("нарисуйте кота", bot.DRAW_TRIGGER_PREFIXES, "нарисуйте"),
|
| 331 |
+
# Английские триггеры из /start.
|
| 332 |
+
("draw a cat on the beach", bot.DRAW_TRIGGER_PREFIXES, "draw a"),
|
| 333 |
+
("generate an image of a castle", bot.DRAW_TRIGGER_PREFIXES, "generate an image"),
|
| 334 |
+
("read this out loud please", bot.TTS_TRIGGER_PREFIXES, "read this out loud"),
|
| 335 |
+
("voice this message", bot.TTS_TRIGGER_PREFIXES, "voice this"),
|
| 336 |
+
])
|
| 337 |
+
def test_match_trigger_prefix_finds_triggers(text, prefixes, expected):
|
| 338 |
+
assert bot._match_trigger_prefix(text, prefixes) == expected
|
| 339 |
+
|
| 340 |
+
|
| 341 |
+
@pytest.mark.parametrize(("text", "prefixes"), [
|
| 342 |
+
("объясни, как я мог бы нарисовать домик карандашом", bot.DRAW_TRIGGER_PREFIXES),
|
| 343 |
+
("что значит слово озвучь на украинском", bot.TTS_TRIGGER_PREFIXES),
|
| 344 |
+
("привет, как дела?", bot.DRAW_TRIGGER_PREFIXES),
|
| 345 |
+
("привет, как дела?", bot.TTS_TRIGGER_PREFIXES),
|
| 346 |
+
# "хочу/сделай картинку" намеренно не триггеры: путаются с правкой присланного фото.
|
| 347 |
+
("хочу картинку показать тебе", bot.DRAW_TRIGGER_PREFIXES),
|
| 348 |
+
("сделай картинку ярче", bot.DRAW_TRIGGER_PREFIXES),
|
| 349 |
+
# Граница слова: голый startswith давал мусор ("нарисуйка" — с "нарисуй").
|
| 350 |
+
("нарисуйка", bot.DRAW_TRIGGER_PREFIXES),
|
| 351 |
+
("how are you", bot.DRAW_TRIGGER_PREFIXES),
|
| 352 |
+
("how are you", bot.TTS_TRIGGER_PREFIXES),
|
| 353 |
+
# Идиома "draw conclusions", а не просьба нарисовать.
|
| 354 |
+
("draw some conclusions here", bot.DRAW_TRIGGER_PREFIXES),
|
| 355 |
+
])
|
| 356 |
+
def test_match_trigger_prefix_rejects_non_triggers(text, prefixes):
|
| 357 |
+
assert bot._match_trigger_prefix(text, prefixes) is None
|
| 358 |
|
| 359 |
|
| 360 |
def test_strip_reply_marker_treats_demonstratives_as_empty():
|
|
|
|
| 371 |
assert bot._strip_reply_marker("этот текст, пожалуйста") == "этот текст, пожалуйста"
|
| 372 |
|
| 373 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 374 |
def test_tts_trigger_this_with_reply_voices_replied_message(rate_guard_setup):
|
| 375 |
# Регрессия (сентябрь 2026): "озвучь это" в ответ на сообщение озвучивало
|
| 376 |
# само слово "это" — остаток после триггера считался содержанием.
|
|
|
|
| 453 |
bot.inline_draw.assert_awaited_once_with(message, "закат над морем")
|
| 454 |
|
| 455 |
|
| 456 |
+
@pytest.mark.parametrize(("prompt", "expected"), [
|
| 457 |
+
("нарисуй девушку в стиле аниме", "flux-anime"),
|
| 458 |
+
("draw a chibi character", "flux-anime"),
|
| 459 |
+
("нарисуй дракона в фэнтезийном замке", "dreamshaper"),
|
| 460 |
+
("concept art of an elf wizard", "dreamshaper"),
|
| 461 |
+
("сделай фотореалистичный портрет кота", "flux-realism"),
|
| 462 |
+
("realistic photo of a mountain", "flux-realism"),
|
| 463 |
+
("быстрый набросок логотипа", "turbo"),
|
| 464 |
+
("космическая станция на орбите Земли", bot.DEFAULT_POLLINATIONS_IMAGE_MODEL),
|
| 465 |
+
("", bot.DEFAULT_POLLINATIONS_IMAGE_MODEL),
|
| 466 |
+
# Стилевой сигнал важнее просьбы "побыстрее" (порядок проверок в _pick_image_model).
|
| 467 |
+
("быстро нарисуй аниме-девушку", "flux-anime"),
|
| 468 |
+
])
|
| 469 |
+
def test_pick_image_model(prompt, expected):
|
| 470 |
+
assert bot._pick_image_model(prompt) == expected
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 471 |
|
| 472 |
|
| 473 |
def test_imgmodel_command_and_callback_removed():
|
|
|
|
| 748 |
assert len(lumen_limits.user_rate_limits[456]) == 1
|
| 749 |
|
| 750 |
|
| 751 |
+
@pytest.mark.parametrize(("text", "expected"), [
|
| 752 |
+
("посоветуй фильм", "film"),
|
| 753 |
+
("порекомендуй интересную книгу", "books"),
|
| 754 |
+
("подскажи музыку для тренировки", "music"),
|
| 755 |
+
("накидай сериалов", "series"),
|
| 756 |
+
("придумай игру для компании", "games"),
|
| 757 |
# Английские запросы — тот же детектор (дефолтный язык бота — en).
|
| 758 |
+
("recommend a movie", "film"),
|
| 759 |
+
("suggest music for training", "music"),
|
| 760 |
+
# Существительные и инфинитивы по-прежнему ведут в games.
|
| 761 |
+
("посоветуй игры", "games"),
|
| 762 |
+
("посоветуй во что поиграть", "games"),
|
| 763 |
# Длинный запрос с деталями — обычным путём в модель, без кнопок.
|
| 764 |
+
("посоветуй фильм про космос, ужасы, 2024 год, длинны�� список", None),
|
| 765 |
+
("привет, как дела?", None),
|
| 766 |
+
("нарисуй кота", None),
|
| 767 |
+
("", None),
|
| 768 |
# Латиница — только по границам слов: "notebook" — не книги.
|
| 769 |
+
("recommend a notebook", None),
|
| 770 |
+
# Кириллица — по началу слова: "тигр" — не игры (AUD-E-006).
|
| 771 |
+
("посоветуй тигра", None),
|
| 772 |
+
# Префикс "игр" не ловит глаголы: "кто играет" — не запрос игры.
|
| 773 |
+
("посоветуй кто играет сегодня", None),
|
| 774 |
+
])
|
| 775 |
+
def test_match_pick_request(text, expected):
|
| 776 |
+
assert bot.match_pick_request(text) == expected
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 777 |
|
| 778 |
|
| 779 |
def test_pick_question_sent_instead_of_ai_route(rate_guard_setup, monkeypatch):
|
tests/test_bot_routes.py
CHANGED
|
@@ -20,40 +20,25 @@ from tests.bot_test_helpers import (
|
|
| 20 |
)
|
| 21 |
|
| 22 |
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
def
|
| 32 |
-
assert bot._classify_model_error(
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
def test_classify_model_error_forbidden():
|
| 36 |
-
assert bot._classify_model_error(403, "") == "forbidden"
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
def test_classify_model_error_unavailable():
|
| 40 |
-
assert bot._classify_model_error(404, "") == "unavailable"
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
def test_classify_model_error_other_for_unknown():
|
| 44 |
-
assert bot._classify_model_error(500, "some random error") == "other"
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
def test_next_fallback_model_skips_tried():
|
| 48 |
-
assert bot._next_fallback_model({"a"}, ["a", "b", "c"]) == "b"
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
def test_next_fallback_model_all_tried_returns_none():
|
| 52 |
-
assert bot._next_fallback_model({"a", "b", "c"}, ["a", "b", "c"]) is None
|
| 53 |
|
| 54 |
|
| 55 |
-
|
| 56 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
|
| 58 |
|
| 59 |
def test_error_status_reads_status_code_attribute():
|
|
@@ -211,13 +196,11 @@ def test_ask_gemini_scrubs_identity_leak_before_storing_history():
|
|
| 211 |
bot.chat_state.pop(chat_id, None)
|
| 212 |
|
| 213 |
|
| 214 |
-
|
|
|
|
| 215 |
# РЕГРЕССИЯ (24.07.2026): раньше запасным вариантом на случай пустого model_chain
|
| 216 |
-
#
|
| 217 |
-
#
|
| 218 |
-
# HTTP 404 "unavailable for free") и по этой же причине уже исключена из
|
| 219 |
-
# _OR_LIGHT_ORDER/_OR_HEAVY_ORDER. Проверяем, что дефолт теперь ссылается на
|
| 220 |
-
# актуальный _OR_LIGHT_ORDER, а не на захардкоженную мёртвую модель.
|
| 221 |
chat_id = 999401
|
| 222 |
|
| 223 |
calls = []
|
|
@@ -229,8 +212,13 @@ def test_ask_openrouter_text_empty_model_chain_fallback_is_not_dead_model():
|
|
| 229 |
original = bot._or_chat_completion_with_fallback
|
| 230 |
bot._or_chat_completion_with_fallback = fake_or_fallback
|
| 231 |
try:
|
| 232 |
-
|
| 233 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 234 |
assert "meta-llama/llama-3.3-70b-instruct:free" not in calls[0]
|
| 235 |
finally:
|
| 236 |
bot._or_chat_completion_with_fallback = original
|
|
@@ -388,15 +376,20 @@ def test_extract_gemini_answer_reports_non_malformed_block_without_retry_state()
|
|
| 388 |
assert "SAFETY" in ans
|
| 389 |
|
| 390 |
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
# от
|
| 394 |
-
#
|
|
|
|
| 395 |
async def retry_raises(*, model, contents, config=None):
|
| 396 |
raise RuntimeError("transient 500 on retry")
|
| 397 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 398 |
fake_client = MagicMock()
|
| 399 |
-
fake_client.aio.models.generate_content =
|
| 400 |
original_client = bot.client
|
| 401 |
bot.client = fake_client
|
| 402 |
try:
|
|
@@ -556,28 +549,6 @@ def test_ask_gemini_attempt_respects_short_budget(monkeypatch):
|
|
| 556 |
bot.chat_state.pop(chat_id, None)
|
| 557 |
|
| 558 |
|
| 559 |
-
def test_gemini_spent_budget_does_not_pretend_blocked(monkeypatch):
|
| 560 |
-
# Регрессия (враждебное ревью 27.09.2026): когда повтор после битого вызова не
|
| 561 |
-
# влезал в бюджет, функция возвращала непустую строку "[Ответ заблокирован...]",
|
| 562 |
-
# ask_gemini считал её успехом и юзер получал её вместо ответа следующей модели.
|
| 563 |
-
async def must_not_run(*, model, contents, config=None):
|
| 564 |
-
raise AssertionError("retry must not run on a spent budget")
|
| 565 |
-
|
| 566 |
-
fake_client = MagicMock()
|
| 567 |
-
fake_client.aio.models.generate_content = must_not_run
|
| 568 |
-
original_client = bot.client
|
| 569 |
-
bot.client = fake_client
|
| 570 |
-
try:
|
| 571 |
-
resp = _FakeGeminiResponse(text="", candidates=[_FakeCandidate(finish_reason="MALFORMED_FUNCTION_CALL")])
|
| 572 |
-
ans = asyncio.run(bot._extract_gemini_answer_text(
|
| 573 |
-
resp, model_id="gemini-3.8-flash", call_contents=[], gconfig=None,
|
| 574 |
-
deadline=time.monotonic() - 1.0,
|
| 575 |
-
))
|
| 576 |
-
assert ans == "", "пустой ответ должен уводить маршрут на следующую модель"
|
| 577 |
-
finally:
|
| 578 |
-
bot.client = original_client
|
| 579 |
-
|
| 580 |
-
|
| 581 |
def test_is_gemini_daily_quota_recognizes_google_per_day_metric():
|
| 582 |
# Google пишет суточную квоту слитно в имени метрики
|
| 583 |
# (GenerateRequestsPerDayPerProjectPerModel) — без токена perday она
|
|
@@ -1004,32 +975,14 @@ def test_ask_gemini_raises_when_route_budget_exceeded():
|
|
| 1004 |
bot.chat_state.pop(chat_id, None)
|
| 1005 |
|
| 1006 |
|
| 1007 |
-
|
| 1008 |
-
|
| 1009 |
-
|
| 1010 |
-
|
| 1011 |
-
raise bot.OpenRouterAPIError("rate limit exceeded", status_code=429)
|
| 1012 |
-
return {"choices": [{"message": {"content": "ответ от model-b"}}]}
|
| 1013 |
-
|
| 1014 |
-
original = bot._or_request
|
| 1015 |
-
bot._or_request = fake_or_request
|
| 1016 |
-
try:
|
| 1017 |
-
messages = [{"role": "system", "content": "sys"}, {"role": "user", "content": "hi"}]
|
| 1018 |
-
answer, used = asyncio.run(bot._or_chat_completion_with_fallback(messages, ["model-a", "model-b"], "model-a"))
|
| 1019 |
-
assert answer == "ответ от model-b"
|
| 1020 |
-
assert used == "model-b"
|
| 1021 |
-
finally:
|
| 1022 |
-
bot._or_request = original
|
| 1023 |
-
|
| 1024 |
-
|
| 1025 |
-
def test_or_chat_completion_with_fallback_switches_model_on_permanent_looking_error():
|
| 1026 |
-
# Даже "постоянная" на вид ошибка (403 forbidden) не должна обрывать переход
|
| 1027 |
-
# к следующей модели — при attempts_per_model=1 переход к следующей модели
|
| 1028 |
-
# происходит независимо от классификации (см. комментарий в самой функции).
|
| 1029 |
async def fake_or_request(path, method="GET", *, json_body=None, deadline=None):
|
| 1030 |
model = json_body["model"]
|
| 1031 |
if model == "model-a":
|
| 1032 |
-
raise bot.OpenRouterAPIError(
|
| 1033 |
return {"choices": [{"message": {"content": "ответ от model-b"}}]}
|
| 1034 |
|
| 1035 |
original = bot._or_request
|
|
@@ -1043,28 +996,6 @@ def test_or_chat_completion_with_fallback_switches_model_on_permanent_looking_er
|
|
| 1043 |
bot._or_request = original
|
| 1044 |
|
| 1045 |
|
| 1046 |
-
def test_ask_openrouter_multimodal_empty_model_chain_fallback_is_current_vision_order():
|
| 1047 |
-
# РЕГРЕССИЯ (аудит техдолга): раньше здесь стоял захардкоженный литерал
|
| 1048 |
-
# "nvidia/nemotron-nano-12b-v2-vl:free" — тот же класс бага, что уже был найден
|
| 1049 |
-
# и исправлен в ask_openrouter_text (см. test_ask_openrouter_text_empty_model_chain_
|
| 1050 |
-
# fallback_is_not_dead_model выше). Теперь дефолт ссылается на _OR_VISION_ORDER[0].
|
| 1051 |
-
chat_id = 999410
|
| 1052 |
-
calls = []
|
| 1053 |
-
|
| 1054 |
-
async def fake_or_fallback(messages, trial_models, primary_model_id, **kwargs):
|
| 1055 |
-
calls.append(trial_models)
|
| 1056 |
-
return "ответ", trial_models[0]
|
| 1057 |
-
|
| 1058 |
-
original = bot._or_chat_completion_with_fallback
|
| 1059 |
-
bot._or_chat_completion_with_fallback = fake_or_fallback
|
| 1060 |
-
try:
|
| 1061 |
-
asyncio.run(bot.ask_openrouter_multimodal(chat_id, "привет", (b"fake", "image/jpeg"), "photo.jpg", model_chain=[]))
|
| 1062 |
-
assert calls[0] == [bot._OR_VISION_ORDER[0]]
|
| 1063 |
-
finally:
|
| 1064 |
-
bot._or_chat_completion_with_fallback = original
|
| 1065 |
-
bot.chat_state.pop(chat_id, None)
|
| 1066 |
-
|
| 1067 |
-
|
| 1068 |
def test_ask_openrouter_multimodal_sends_all_album_images_not_just_first():
|
| 1069 |
# Внешний аудит: уходила только первая фотография альбома, остальные 2–9 молча терялись.
|
| 1070 |
chat_id = 999411
|
|
@@ -1163,26 +1094,27 @@ def test_or_chat_completion_with_fallback_no_longer_accepts_attempts_per_model()
|
|
| 1163 |
assert "attempts_per_model" not in sig.parameters
|
| 1164 |
|
| 1165 |
|
| 1166 |
-
|
| 1167 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1168 |
def request(self, *args, **kwargs):
|
| 1169 |
-
raise RuntimeError("connection failed, headers
|
| 1170 |
|
| 1171 |
async def fake_get_http_session():
|
| 1172 |
-
return
|
| 1173 |
|
| 1174 |
-
|
| 1175 |
-
|
| 1176 |
-
|
| 1177 |
-
|
| 1178 |
-
|
| 1179 |
-
|
| 1180 |
-
asyncio.run(bot._or_request("chat/completions", "POST", json_body={"model": "x"}))
|
| 1181 |
-
assert "fake-secret-or-key-123" not in str(exc_info.value)
|
| 1182 |
-
assert "<KEY>" in str(exc_info.value)
|
| 1183 |
-
finally:
|
| 1184 |
-
bot._get_http_session = original_get_session
|
| 1185 |
-
bot.OPENROUTER_API_KEY = original_key
|
| 1186 |
|
| 1187 |
|
| 1188 |
def test_probe_or_model_liveness_warns_on_dead_model_pattern(caplog):
|
|
@@ -1393,23 +1325,6 @@ def test_groq_request_requires_key(monkeypatch):
|
|
| 1393 |
asyncio.run(bot._groq_request("chat/completions", "POST", json_body={"model": "x"}))
|
| 1394 |
|
| 1395 |
|
| 1396 |
-
def test_groq_request_scrubs_api_key_from_network_exception_message(monkeypatch):
|
| 1397 |
-
# Тот же defense-in-depth, что у _or_request: ключ не должен светиться в тексте ошибок.
|
| 1398 |
-
class _FakeSessionRaisingWithKey:
|
| 1399 |
-
def request(self, *args, **kwargs):
|
| 1400 |
-
raise RuntimeError("connection failed, headers: Bearer fake-groq-key-123")
|
| 1401 |
-
|
| 1402 |
-
async def fake_get_http_session():
|
| 1403 |
-
return _FakeSessionRaisingWithKey()
|
| 1404 |
-
|
| 1405 |
-
monkeypatch.setattr(bot, "_get_http_session", fake_get_http_session)
|
| 1406 |
-
monkeypatch.setattr(bot, "GROQ_API_KEY", "fake-groq-key-123")
|
| 1407 |
-
with pytest.raises(bot.GroqAPIError) as exc_info:
|
| 1408 |
-
asyncio.run(bot._groq_request("chat/completions", "POST", json_body={"model": "x"}))
|
| 1409 |
-
assert "fake-groq-key-123" not in str(exc_info.value)
|
| 1410 |
-
assert "<KEY>" in str(exc_info.value)
|
| 1411 |
-
|
| 1412 |
-
|
| 1413 |
def test_route_error_reply_text_maps_groq_error():
|
| 1414 |
# Ошибка Groq — пользовательский текст через общую классификацию, без сырого API.
|
| 1415 |
exc = bot.GroqAPIError("Rate limit reached", status_code=429)
|
|
@@ -1591,53 +1506,28 @@ def test_transcribe_audio_capped_by_own_limit(monkeypatch):
|
|
| 1591 |
assert captured["timeout"].total < bot.ROUTE_MODEL_TIMEOUT_SEC
|
| 1592 |
|
| 1593 |
|
| 1594 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1595 |
# Аудит A4-05: история резалась молчаливым срезом вместо _trim_history с саммари.
|
| 1596 |
import lumen_chat_state
|
| 1597 |
from collections import deque
|
| 1598 |
chat_id = 999811
|
| 1599 |
|
| 1600 |
async def fake_fallback(messages, trial_models, primary_model_id, **kwargs):
|
| 1601 |
-
return
|
| 1602 |
-
|
| 1603 |
-
async def fake_summarize(text):
|
| 1604 |
-
return "итог: погода и коты"
|
| 1605 |
-
|
| 1606 |
-
monkeypatch.setattr(bot, "_or_chat_completion_with_fallback", fake_fallback)
|
| 1607 |
-
monkeypatch.setattr(lumen_chat_state, "_summarize_text", fake_summarize)
|
| 1608 |
-
state = bot.get_state(chat_id)
|
| 1609 |
-
state["history"] = [
|
| 1610 |
-
{"role": "user" if i % 2 == 0 else "assistant", "content": f"сообщение {i}"}
|
| 1611 |
-
for i in range(105)
|
| 1612 |
-
]
|
| 1613 |
-
state["ctx"] = deque()
|
| 1614 |
-
try:
|
| 1615 |
-
asyncio.run(bot.ask_openrouter_text(chat_id, "новый вопрос", model_chain=["m1"]))
|
| 1616 |
-
history = bot.chat_state[chat_id]["history"]
|
| 1617 |
-
assert len(history) == 81
|
| 1618 |
-
assert history[0]["content"].startswith("[Ранее в диалоге]")
|
| 1619 |
-
assert "погода" in history[0]["content"]
|
| 1620 |
-
assert history[-2:] == [
|
| 1621 |
-
{"role": "user", "content": "новый вопрос"},
|
| 1622 |
-
{"role": "assistant", "content": "ответ"},
|
| 1623 |
-
]
|
| 1624 |
-
finally:
|
| 1625 |
-
bot.chat_state.pop(chat_id, None)
|
| 1626 |
-
|
| 1627 |
-
|
| 1628 |
-
def test_ask_groq_text_trims_history_with_summary_not_silent_cut(monkeypatch):
|
| 1629 |
-
# Тот же молчаливый срез жил в ask_groq_text — фиксим парой (аудит A4-05: везде _trim_history).
|
| 1630 |
-
import lumen_chat_state
|
| 1631 |
-
from collections import deque
|
| 1632 |
-
chat_id = 999812
|
| 1633 |
|
| 1634 |
async def fake_groq_request(path, method="GET", *, json_body=None, deadline=None):
|
| 1635 |
-
return {"choices": [{"message": {"content":
|
|
|
|
|
|
|
| 1636 |
|
| 1637 |
async def fake_summarize(text):
|
| 1638 |
return "итог: погода и коты"
|
| 1639 |
|
| 1640 |
-
monkeypatch.setattr(bot,
|
| 1641 |
monkeypatch.setattr(lumen_chat_state, "_summarize_text", fake_summarize)
|
| 1642 |
state = bot.get_state(chat_id)
|
| 1643 |
state["history"] = [
|
|
@@ -1646,14 +1536,14 @@ def test_ask_groq_text_trims_history_with_summary_not_silent_cut(monkeypatch):
|
|
| 1646 |
]
|
| 1647 |
state["ctx"] = deque()
|
| 1648 |
try:
|
| 1649 |
-
asyncio.run(bot
|
| 1650 |
history = bot.chat_state[chat_id]["history"]
|
| 1651 |
assert len(history) == 81
|
| 1652 |
assert history[0]["content"].startswith("[Ранее в диалоге]")
|
| 1653 |
assert "погода" in history[0]["content"]
|
| 1654 |
assert history[-2:] == [
|
| 1655 |
{"role": "user", "content": "новый вопрос"},
|
| 1656 |
-
{"role": "assistant", "content":
|
| 1657 |
]
|
| 1658 |
finally:
|
| 1659 |
bot.chat_state.pop(chat_id, None)
|
|
@@ -1707,26 +1597,6 @@ def test_run_route_marks_unsupported_media_for_text_fallback(monkeypatch):
|
|
| 1707 |
bot.chat_state.pop(chat_id, None)
|
| 1708 |
|
| 1709 |
|
| 1710 |
-
def test_extract_gemini_answer_empty_retry_goes_to_next_model():
|
| 1711 |
-
# Аудит A4-10: повтор прошёл, но вернул пусто без исчерпания бюджета — это
|
| 1712 |
-
# повод на следующую модель, а не заглушка блокировки как успех.
|
| 1713 |
-
async def retry_empty(*, model, contents, config=None):
|
| 1714 |
-
return _FakeGeminiResponse(text="", candidates=[])
|
| 1715 |
-
|
| 1716 |
-
fake_client = MagicMock()
|
| 1717 |
-
fake_client.aio.models.generate_content = retry_empty
|
| 1718 |
-
original_client = bot.client
|
| 1719 |
-
bot.client = fake_client
|
| 1720 |
-
try:
|
| 1721 |
-
resp = _FakeGeminiResponse(text="", candidates=[_FakeCandidate(finish_reason="MALFORMED_FUNCTION_CALL")])
|
| 1722 |
-
ans = asyncio.run(bot._extract_gemini_answer_text(
|
| 1723 |
-
resp, model_id="gemini-3.8-flash", call_contents=[], gconfig=None,
|
| 1724 |
-
))
|
| 1725 |
-
assert ans == ""
|
| 1726 |
-
finally:
|
| 1727 |
-
bot.client = original_client
|
| 1728 |
-
|
| 1729 |
-
|
| 1730 |
def test_run_route_cleans_placeholder_on_cancel(monkeypatch):
|
| 1731 |
# Аудит A4-06: отмена посреди ответа оставляла "…" в чате навсегда.
|
| 1732 |
placeholder = _FakeSentMessage()
|
|
@@ -1904,18 +1774,21 @@ def test_daily_limit_denial_counted_and_refused(rate_guard_setup, monkeypatch):
|
|
| 1904 |
bot.chat_state.pop(123, None)
|
| 1905 |
|
| 1906 |
|
| 1907 |
-
|
|
|
|
| 1908 |
# Что защищает: единственную точку калибровки лимитов по логам для Gemini
|
| 1909 |
# (usage_metadata.prompt_token_count/candidates_token_count/total_token_count).
|
| 1910 |
# Регрессия: тихий дроп usage при рефакторинге ask_gemini — счётчики пропадут из
|
| 1911 |
# логов, лимиты не на чем калибровать. Старые тесты проверяют только текст/историю.
|
|
|
|
| 1912 |
import logging
|
| 1913 |
from types import SimpleNamespace
|
| 1914 |
chat_id = 999901
|
| 1915 |
|
| 1916 |
def fake_generate_content(*, model, contents, config=None):
|
| 1917 |
resp = _FakeGeminiResponse(text="Привет!")
|
| 1918 |
-
|
|
|
|
| 1919 |
return resp
|
| 1920 |
|
| 1921 |
fake_client = MagicMock()
|
|
@@ -1927,33 +1800,13 @@ def test_gemini_success_logs_token_usage_when_api_returns_it(caplog):
|
|
| 1927 |
answer = asyncio.run(bot.ask_gemini(chat_id, "Привет"))
|
| 1928 |
assert answer == "Привет!"
|
| 1929 |
usage_lines = [r.getMessage() for r in caplog.records if r.getMessage().startswith("[usage]")]
|
| 1930 |
-
|
| 1931 |
-
|
| 1932 |
-
|
| 1933 |
-
|
| 1934 |
-
|
| 1935 |
-
|
| 1936 |
-
|
| 1937 |
-
caplog.clear()
|
| 1938 |
-
|
| 1939 |
-
|
| 1940 |
-
def test_gemini_success_logs_no_usage_line_without_metadata(caplog):
|
| 1941 |
-
# Пара к предыдущему: если API usage не вернуло — строка [usage] не пишется,
|
| 1942 |
-
# лог не засоряется пустышками. Старые тесты такого не проверяют.
|
| 1943 |
-
import logging
|
| 1944 |
-
chat_id = 999902
|
| 1945 |
-
|
| 1946 |
-
def fake_generate_content(*, model, contents, config=None):
|
| 1947 |
-
return _FakeGeminiResponse(text="Привет!")
|
| 1948 |
-
|
| 1949 |
-
fake_client = MagicMock()
|
| 1950 |
-
fake_client.aio.models.generate_content = AsyncMock(side_effect=fake_generate_content)
|
| 1951 |
-
original_client = bot.client
|
| 1952 |
-
bot.client = fake_client
|
| 1953 |
-
try:
|
| 1954 |
-
with caplog.at_level(logging.INFO, logger="bot"):
|
| 1955 |
-
asyncio.run(bot.ask_gemini(chat_id, "Привет"))
|
| 1956 |
-
assert not [r for r in caplog.records if r.getMessage().startswith("[usage]")]
|
| 1957 |
finally:
|
| 1958 |
bot.client = original_client
|
| 1959 |
bot.chat_state.pop(chat_id, None)
|
|
|
|
| 20 |
)
|
| 21 |
|
| 22 |
|
| 23 |
+
@pytest.mark.parametrize(("status", "text", "expected"), [
|
| 24 |
+
(429, "", "rate_limit"),
|
| 25 |
+
(None, "quota exceeded", "rate_limit"),
|
| 26 |
+
(402, "", "paid"),
|
| 27 |
+
(403, "", "forbidden"),
|
| 28 |
+
(404, "", "unavailable"),
|
| 29 |
+
(500, "some random error", "other"),
|
| 30 |
+
])
|
| 31 |
+
def test_classify_model_error(status, text, expected):
|
| 32 |
+
assert bot._classify_model_error(status, text) == expected
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
|
| 34 |
|
| 35 |
+
@pytest.mark.parametrize(("tried", "chain", "expected"), [
|
| 36 |
+
({"a"}, ["a", "b", "c"], "b"),
|
| 37 |
+
({"a", "b", "c"}, ["a", "b", "c"], None),
|
| 38 |
+
(set(), ["a", "b", "c"], "a"),
|
| 39 |
+
])
|
| 40 |
+
def test_next_fallback_model(tried, chain, expected):
|
| 41 |
+
assert bot._next_fallback_model(tried, chain) == expected
|
| 42 |
|
| 43 |
|
| 44 |
def test_error_status_reads_status_code_attribute():
|
|
|
|
| 196 |
bot.chat_state.pop(chat_id, None)
|
| 197 |
|
| 198 |
|
| 199 |
+
@pytest.mark.parametrize("mode", ["text", "vision"])
|
| 200 |
+
def test_empty_model_chain_falls_back_to_current_order_head(mode):
|
| 201 |
# РЕГРЕССИЯ (24.07.2026): раньше запасным вариантом на случай пустого model_chain
|
| 202 |
+
# стоял захардкоженный литерал мёртвой модели; теперь дефолт ссылается на голову
|
| 203 |
+
# актуального ORDER-списка (_OR_LIGHT_ORDER для текста, _OR_VISION_ORDER для vision).
|
|
|
|
|
|
|
|
|
|
| 204 |
chat_id = 999401
|
| 205 |
|
| 206 |
calls = []
|
|
|
|
| 212 |
original = bot._or_chat_completion_with_fallback
|
| 213 |
bot._or_chat_completion_with_fallback = fake_or_fallback
|
| 214 |
try:
|
| 215 |
+
if mode == "text":
|
| 216 |
+
asyncio.run(bot.ask_openrouter_text(chat_id, "привет", model_chain=[]))
|
| 217 |
+
expected = [bot._OR_LIGHT_ORDER[0]]
|
| 218 |
+
else:
|
| 219 |
+
asyncio.run(bot.ask_openrouter_multimodal(chat_id, "привет", (b"fake", "image/jpeg"), "photo.jpg", model_chain=[]))
|
| 220 |
+
expected = [bot._OR_VISION_ORDER[0]]
|
| 221 |
+
assert calls[0] == expected
|
| 222 |
assert "meta-llama/llama-3.3-70b-instruct:free" not in calls[0]
|
| 223 |
finally:
|
| 224 |
bot._or_chat_completion_with_fallback = original
|
|
|
|
| 376 |
assert "SAFETY" in ans
|
| 377 |
|
| 378 |
|
| 379 |
+
@pytest.mark.parametrize("retry_outcome", ["raises", "empty"])
|
| 380 |
+
def test_extract_gemini_answer_failed_retry_goes_to_next_model(retry_outcome):
|
| 381 |
+
# Повтор после битого вызова сам упал (а не просто не влез в бюджет) или
|
| 382 |
+
# вернул пусто — отдаём п��сто, чтобы маршрут ушёл на следующую модель.
|
| 383 |
+
# Раньше пользователь получал "[Ответ заблокирован...]" как готовый ответ.
|
| 384 |
async def retry_raises(*, model, contents, config=None):
|
| 385 |
raise RuntimeError("transient 500 on retry")
|
| 386 |
|
| 387 |
+
async def retry_empty(*, model, contents, config=None):
|
| 388 |
+
return _FakeGeminiResponse(text="", candidates=[])
|
| 389 |
+
|
| 390 |
+
retry_fn = retry_raises if retry_outcome == "raises" else retry_empty
|
| 391 |
fake_client = MagicMock()
|
| 392 |
+
fake_client.aio.models.generate_content = retry_fn
|
| 393 |
original_client = bot.client
|
| 394 |
bot.client = fake_client
|
| 395 |
try:
|
|
|
|
| 549 |
bot.chat_state.pop(chat_id, None)
|
| 550 |
|
| 551 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 552 |
def test_is_gemini_daily_quota_recognizes_google_per_day_metric():
|
| 553 |
# Google пишет суточную квоту слитно в имени метрики
|
| 554 |
# (GenerateRequestsPerDayPerProjectPerModel) — без токена perday она
|
|
|
|
| 975 |
bot.chat_state.pop(chat_id, None)
|
| 976 |
|
| 977 |
|
| 978 |
+
@pytest.mark.parametrize(("status_code", "message"), [(429, "rate limit exceeded"), (403, "forbidden")])
|
| 979 |
+
def test_or_chat_completion_with_fallback_switches_model(status_code, message):
|
| 980 |
+
# И минутный 429, и "постоянная" на вид 403 не обрывают переход к следующей
|
| 981 |
+
# модели: при attempts_per_model=1 переход происходит независимо от классификации.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 982 |
async def fake_or_request(path, method="GET", *, json_body=None, deadline=None):
|
| 983 |
model = json_body["model"]
|
| 984 |
if model == "model-a":
|
| 985 |
+
raise bot.OpenRouterAPIError(message, status_code=status_code)
|
| 986 |
return {"choices": [{"message": {"content": "ответ от model-b"}}]}
|
| 987 |
|
| 988 |
original = bot._or_request
|
|
|
|
| 996 |
bot._or_request = original
|
| 997 |
|
| 998 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 999 |
def test_ask_openrouter_multimodal_sends_all_album_images_not_just_first():
|
| 1000 |
# Внешний аудит: уходила только первая фотография альбома, остальные 2–9 молча терялись.
|
| 1001 |
chat_id = 999411
|
|
|
|
| 1094 |
assert "attempts_per_model" not in sig.parameters
|
| 1095 |
|
| 1096 |
|
| 1097 |
+
@pytest.mark.parametrize(("request_name", "key_attr", "fake_key", "error_cls"), [
|
| 1098 |
+
("_or_request", "OPENROUTER_API_KEY", "fake-secret-or-key-123", "OpenRouterAPIError"),
|
| 1099 |
+
("_groq_request", "GROQ_API_KEY", "fake-groq-key-123", "GroqAPIError"),
|
| 1100 |
+
])
|
| 1101 |
+
def test_provider_request_scrubs_api_key_from_network_exception_message(
|
| 1102 |
+
monkeypatch, request_name, key_attr, fake_key, error_cls,
|
| 1103 |
+
):
|
| 1104 |
+
# Defense-in-depth у обоих провайдеров: ключ не светится в тексте ошибок.
|
| 1105 |
+
class _FakeSessionRaisingWithKey:
|
| 1106 |
def request(self, *args, **kwargs):
|
| 1107 |
+
raise RuntimeError(f"connection failed, headers: Bearer {fake_key}")
|
| 1108 |
|
| 1109 |
async def fake_get_http_session():
|
| 1110 |
+
return _FakeSessionRaisingWithKey()
|
| 1111 |
|
| 1112 |
+
monkeypatch.setattr(bot, "_get_http_session", fake_get_http_session)
|
| 1113 |
+
monkeypatch.setattr(bot, key_attr, fake_key)
|
| 1114 |
+
with pytest.raises(getattr(bot, error_cls)) as exc_info:
|
| 1115 |
+
asyncio.run(getattr(bot, request_name)("chat/completions", "POST", json_body={"model": "x"}))
|
| 1116 |
+
assert fake_key not in str(exc_info.value)
|
| 1117 |
+
assert "<KEY>" in str(exc_info.value)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1118 |
|
| 1119 |
|
| 1120 |
def test_probe_or_model_liveness_warns_on_dead_model_pattern(caplog):
|
|
|
|
| 1325 |
asyncio.run(bot._groq_request("chat/completions", "POST", json_body={"model": "x"}))
|
| 1326 |
|
| 1327 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1328 |
def test_route_error_reply_text_maps_groq_error():
|
| 1329 |
# Ошибка Groq — пользовательский текст через общую классификацию, без сырого API.
|
| 1330 |
exc = bot.GroqAPIError("Rate limit reached", status_code=429)
|
|
|
|
| 1506 |
assert captured["timeout"].total < bot.ROUTE_MODEL_TIMEOUT_SEC
|
| 1507 |
|
| 1508 |
|
| 1509 |
+
@pytest.mark.parametrize(("ask_name", "stub_name", "answer"), [
|
| 1510 |
+
("ask_openrouter_text", "_or_chat_completion_with_fallback", "ответ"),
|
| 1511 |
+
("ask_groq_text", "_groq_request", "ответ groq"),
|
| 1512 |
+
])
|
| 1513 |
+
def test_text_provider_trims_history_with_summary_not_silent_cut(monkeypatch, ask_name, stub_name, answer):
|
| 1514 |
# Аудит A4-05: история резалась молчаливым срезом вместо _trim_history с саммари.
|
| 1515 |
import lumen_chat_state
|
| 1516 |
from collections import deque
|
| 1517 |
chat_id = 999811
|
| 1518 |
|
| 1519 |
async def fake_fallback(messages, trial_models, primary_model_id, **kwargs):
|
| 1520 |
+
return answer, trial_models[0]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1521 |
|
| 1522 |
async def fake_groq_request(path, method="GET", *, json_body=None, deadline=None):
|
| 1523 |
+
return {"choices": [{"message": {"content": answer}}]}
|
| 1524 |
+
|
| 1525 |
+
stubs = {"_or_chat_completion_with_fallback": fake_fallback, "_groq_request": fake_groq_request}
|
| 1526 |
|
| 1527 |
async def fake_summarize(text):
|
| 1528 |
return "итог: погода и коты"
|
| 1529 |
|
| 1530 |
+
monkeypatch.setattr(bot, stub_name, stubs[stub_name])
|
| 1531 |
monkeypatch.setattr(lumen_chat_state, "_summarize_text", fake_summarize)
|
| 1532 |
state = bot.get_state(chat_id)
|
| 1533 |
state["history"] = [
|
|
|
|
| 1536 |
]
|
| 1537 |
state["ctx"] = deque()
|
| 1538 |
try:
|
| 1539 |
+
asyncio.run(getattr(bot, ask_name)(chat_id, "новый вопрос", model_chain=["m1"]))
|
| 1540 |
history = bot.chat_state[chat_id]["history"]
|
| 1541 |
assert len(history) == 81
|
| 1542 |
assert history[0]["content"].startswith("[Ранее в диалоге]")
|
| 1543 |
assert "погода" in history[0]["content"]
|
| 1544 |
assert history[-2:] == [
|
| 1545 |
{"role": "user", "content": "новый вопрос"},
|
| 1546 |
+
{"role": "assistant", "content": answer},
|
| 1547 |
]
|
| 1548 |
finally:
|
| 1549 |
bot.chat_state.pop(chat_id, None)
|
|
|
|
| 1597 |
bot.chat_state.pop(chat_id, None)
|
| 1598 |
|
| 1599 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1600 |
def test_run_route_cleans_placeholder_on_cancel(monkeypatch):
|
| 1601 |
# Аудит A4-06: отмена посреди ответа оставляла "…" в чате навсегда.
|
| 1602 |
placeholder = _FakeSentMessage()
|
|
|
|
| 1774 |
bot.chat_state.pop(123, None)
|
| 1775 |
|
| 1776 |
|
| 1777 |
+
@pytest.mark.parametrize("with_usage", [True, False])
|
| 1778 |
+
def test_gemini_success_logs_token_usage(caplog, with_usage):
|
| 1779 |
# Что защищает: единственную точку калибровки лимитов по логам для Gemini
|
| 1780 |
# (usage_metadata.prompt_token_count/candidates_token_count/total_token_count).
|
| 1781 |
# Регрессия: тихий дроп usage при рефакторинге ask_gemini — счётчики пропадут из
|
| 1782 |
# логов, лимиты не на чем калибровать. Старые тесты проверяют только текст/историю.
|
| 1783 |
+
# Без usage от API строка [usage] не пишется, лог не засоряется пустышками.
|
| 1784 |
import logging
|
| 1785 |
from types import SimpleNamespace
|
| 1786 |
chat_id = 999901
|
| 1787 |
|
| 1788 |
def fake_generate_content(*, model, contents, config=None):
|
| 1789 |
resp = _FakeGeminiResponse(text="Привет!")
|
| 1790 |
+
if with_usage:
|
| 1791 |
+
resp.usage_metadata = SimpleNamespace(prompt_token_count=10, candidates_token_count=20, total_token_count=30)
|
| 1792 |
return resp
|
| 1793 |
|
| 1794 |
fake_client = MagicMock()
|
|
|
|
| 1800 |
answer = asyncio.run(bot.ask_gemini(chat_id, "Привет"))
|
| 1801 |
assert answer == "Привет!"
|
| 1802 |
usage_lines = [r.getMessage() for r in caplog.records if r.getMessage().startswith("[usage]")]
|
| 1803 |
+
if with_usage:
|
| 1804 |
+
assert len(usage_lines) == 1
|
| 1805 |
+
line = usage_lines[0]
|
| 1806 |
+
assert f"chat={chat_id}" in line and "provider=gemini" in line
|
| 1807 |
+
assert "prompt=10" in line and "completion=20" in line and "total=30" in line
|
| 1808 |
+
else:
|
| 1809 |
+
assert usage_lines == []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1810 |
finally:
|
| 1811 |
bot.client = original_client
|
| 1812 |
bot.chat_state.pop(chat_id, None)
|
tests/test_bot_state.py
CHANGED
|
@@ -50,31 +50,15 @@ def test_split_text_chunks_preserves_all_words():
|
|
| 50 |
assert " ".join(chunks).split() == text.split()
|
| 51 |
|
| 52 |
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
def
|
| 61 |
-
assert bot._sanitize_mime_type(
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
def test_sanitize_mime_type_lowercases_valid_mime():
|
| 65 |
-
assert bot._sanitize_mime_type(None, "image/PNG") == "image/png"
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
def test_sanitize_mime_type_octet_stream_falls_back_to_extension():
|
| 69 |
-
assert bot._sanitize_mime_type("file.pdf", "application/octet-stream") == "application/pdf"
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
def test_sanitize_mime_type_no_info_returns_default_fallback():
|
| 73 |
-
assert bot._sanitize_mime_type(None, None) == "application/octet-stream"
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
def test_sanitize_mime_type_audio_ogg_passthrough():
|
| 77 |
-
assert bot._sanitize_mime_type(None, "audio/ogg") == "audio/ogg"
|
| 78 |
|
| 79 |
|
| 80 |
def test_sanitize_mime_type_maps_containers_to_supported_mimes():
|
|
@@ -104,35 +88,27 @@ def test_ensure_prompt_text_gif_gets_animation_prompt_not_generic_image():
|
|
| 104 |
assert "картинке" in bot._ensure_prompt_text(None, "image/jpeg")
|
| 105 |
|
| 106 |
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
assert bot.is_youtube("https://youtu.be/abc123") is True
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
def test_is_youtube_false_for_other_url():
|
| 120 |
-
assert bot.is_youtube("https://example.com") is False
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
def test_extract_url_strips_trailing_punctuation():
|
| 124 |
-
assert bot.extract_url("check this out: https://example.com/page.") == "https://example.com/page"
|
| 125 |
|
| 126 |
|
| 127 |
-
|
| 128 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
|
| 130 |
|
| 131 |
-
def
|
| 132 |
assert bot.clean_mention(f"@{bot.BOT_USERNAME} привет") == "привет"
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
def test_clean_mention_is_case_insensitive():
|
| 136 |
result = bot.clean_mention(f"привет @{bot.BOT_USERNAME.upper()} как дела")
|
| 137 |
assert bot.BOT_USERNAME.lower() not in result.lower()
|
| 138 |
|
|
@@ -669,27 +645,25 @@ def test_sentry_not_initialized_without_dsn_in_test_env():
|
|
| 669 |
assert sentry_sdk.is_initialized() is False
|
| 670 |
|
| 671 |
|
| 672 |
-
|
| 673 |
-
# Р
|
| 674 |
-
|
| 675 |
-
|
|
|
|
|
|
|
| 676 |
import logging as _logging
|
| 677 |
-
|
|
|
|
|
|
|
|
|
|
| 678 |
try:
|
| 679 |
bot._setup_logging()
|
| 680 |
-
assert _logging.getLogger().level == _logging
|
| 681 |
finally:
|
| 682 |
monkeypatch.delenv("LOG_LEVEL", raising=False)
|
| 683 |
bot._setup_logging() # восстановить дефолт INFO для остальных тестов
|
| 684 |
|
| 685 |
|
| 686 |
-
def test_setup_logging_defaults_to_info_when_unset(monkeypatch):
|
| 687 |
-
import logging as _logging
|
| 688 |
-
monkeypatch.delenv("LOG_LEVEL", raising=False)
|
| 689 |
-
bot._setup_logging()
|
| 690 |
-
assert _logging.getLogger().level == _logging.INFO
|
| 691 |
-
|
| 692 |
-
|
| 693 |
def test_setup_logging_uses_bot_logger_name_not_dunder_main():
|
| 694 |
# РЕГРЕССИЯ (аудит логирования): _setup_logging() раньше возвращал
|
| 695 |
# logging.getLogger(__name__) — "bot" при import bot (как в тестах), но
|
|
@@ -1895,28 +1869,21 @@ def test_reject_rate_limited_message_does_not_create_chat_state(monkeypatch):
|
|
| 1895 |
bot.chat_state.pop(chat_id, None)
|
| 1896 |
|
| 1897 |
|
| 1898 |
-
|
| 1899 |
-
|
| 1900 |
-
from_user=SimpleNamespace(id=555), sender_chat=None,
|
| 1901 |
-
chat=SimpleNamespace(id=-100999),
|
| 1902 |
-
)
|
| 1903 |
-
assert bot._rate_limit_key_for_message(msg) == 555
|
| 1904 |
-
|
| 1905 |
-
|
| 1906 |
-
def test_rate_limit_key_for_message_falls_back_to_sender_chat_without_from_user():
|
| 1907 |
# Сообщение "от имени канала" — from_user отсутствует, но есть sender_chat.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1908 |
msg = SimpleNamespace(
|
| 1909 |
-
from_user=
|
|
|
|
| 1910 |
chat=SimpleNamespace(id=-100999),
|
| 1911 |
)
|
| 1912 |
-
assert bot._rate_limit_key_for_message(msg) ==
|
| 1913 |
-
|
| 1914 |
-
|
| 1915 |
-
def test_rate_limit_key_for_message_falls_back_to_chat_id_as_last_resort():
|
| 1916 |
-
# Ни from_user, ни sender_chat — берём сам chat.id, лишь бы не None (регрессия
|
| 1917 |
-
# на сам факт обхода: раньше это давало полностью нелимитированного отправителя).
|
| 1918 |
-
msg = SimpleNamespace(from_user=None, sender_chat=None, chat=SimpleNamespace(id=-100999))
|
| 1919 |
-
assert bot._rate_limit_key_for_message(msg) == -100999
|
| 1920 |
|
| 1921 |
|
| 1922 |
def test_rate_limit_key_for_message_never_falls_through_to_none():
|
|
@@ -1930,23 +1897,19 @@ def test_rate_limit_key_for_message_never_falls_through_to_none():
|
|
| 1930 |
assert key is not None and key != 0
|
| 1931 |
|
| 1932 |
|
| 1933 |
-
|
| 1934 |
-
|
| 1935 |
-
|
| 1936 |
-
|
| 1937 |
-
|
| 1938 |
-
def test_should_only_record_passively_false_when_mentioned_or_private_or_guest():
|
| 1939 |
-
msg = _FakeIncomingMessage(1)
|
| 1940 |
-
assert bot._should_only_record_passively(msg, "привет", is_private=True, is_guest=False, mentioned=False) is False
|
| 1941 |
-
assert bot._should_only_record_passively(msg, "привет", is_private=False, is_guest=True, mentioned=False) is False
|
| 1942 |
-
assert bot._should_only_record_passively(msg, "привет", is_private=False, is_guest=False, mentioned=True) is False
|
| 1943 |
-
|
| 1944 |
-
|
| 1945 |
-
def test_should_only_record_passively_false_for_tiktok_link_without_mention():
|
| 1946 |
# TikTok-ссылка обрабатывается всегда, даже без упоминания бота в группе.
|
|
|
|
|
|
|
|
|
|
| 1947 |
msg = _FakeIncomingMessage(1)
|
| 1948 |
-
|
| 1949 |
-
|
|
|
|
| 1950 |
|
| 1951 |
|
| 1952 |
def test_handle_message_core_known_route_outcome_logs_as_warning_not_exception(caplog):
|
|
|
|
| 50 |
assert " ".join(chunks).split() == text.split()
|
| 51 |
|
| 52 |
|
| 53 |
+
@pytest.mark.parametrize(("filename", "mime", "expected"), [
|
| 54 |
+
("photo.jpg", "", "image/jpeg"),
|
| 55 |
+
(None, "image/PNG", "image/png"),
|
| 56 |
+
("file.pdf", "application/octet-stream", "application/pdf"),
|
| 57 |
+
(None, None, "application/octet-stream"),
|
| 58 |
+
(None, "audio/ogg", "audio/ogg"),
|
| 59 |
+
])
|
| 60 |
+
def test_sanitize_mime_type(filename, mime, expected):
|
| 61 |
+
assert bot._sanitize_mime_type(filename, mime) == expected
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
|
| 63 |
|
| 64 |
def test_sanitize_mime_type_maps_containers_to_supported_mimes():
|
|
|
|
| 88 |
assert "картинке" in bot._ensure_prompt_text(None, "image/jpeg")
|
| 89 |
|
| 90 |
|
| 91 |
+
@pytest.mark.parametrize(("url", "is_tiktok", "is_youtube"), [
|
| 92 |
+
("https://www.tiktok.com/@user/video/123", True, False),
|
| 93 |
+
("https://youtube.com/watch?v=1", False, True),
|
| 94 |
+
("https://youtu.be/abc123", False, True),
|
| 95 |
+
("https://example.com", False, False),
|
| 96 |
+
])
|
| 97 |
+
def test_is_tiktok_and_is_youtube(url, is_tiktok, is_youtube):
|
| 98 |
+
assert bot.is_tiktok(url) is is_tiktok
|
| 99 |
+
assert bot.is_youtube(url) is is_youtube
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
|
| 101 |
|
| 102 |
+
@pytest.mark.parametrize(("text", "expected"), [
|
| 103 |
+
("check this out: https://example.com/page.", "https://example.com/page"),
|
| 104 |
+
("no url here", None),
|
| 105 |
+
])
|
| 106 |
+
def test_extract_url(text, expected):
|
| 107 |
+
assert bot.extract_url(text) == expected
|
| 108 |
|
| 109 |
|
| 110 |
+
def test_clean_mention():
|
| 111 |
assert bot.clean_mention(f"@{bot.BOT_USERNAME} привет") == "привет"
|
|
|
|
|
|
|
|
|
|
| 112 |
result = bot.clean_mention(f"привет @{bot.BOT_USERNAME.upper()} как дела")
|
| 113 |
assert bot.BOT_USERNAME.lower() not in result.lower()
|
| 114 |
|
|
|
|
| 645 |
assert sentry_sdk.is_initialized() is False
|
| 646 |
|
| 647 |
|
| 648 |
+
@pytest.mark.parametrize(("log_level", "expected"), [
|
| 649 |
+
# Регрессия: уровень был захардкожен INFO — log.debug не печатался ни при каком окружении.
|
| 650 |
+
("DEBUG", "DEBUG"),
|
| 651 |
+
(None, "INFO"),
|
| 652 |
+
])
|
| 653 |
+
def test_setup_logging_level(monkeypatch, log_level, expected):
|
| 654 |
import logging as _logging
|
| 655 |
+
if log_level is None:
|
| 656 |
+
monkeypatch.delenv("LOG_LEVEL", raising=False)
|
| 657 |
+
else:
|
| 658 |
+
monkeypatch.setenv("LOG_LEVEL", log_level)
|
| 659 |
try:
|
| 660 |
bot._setup_logging()
|
| 661 |
+
assert _logging.getLogger().level == getattr(_logging, expected)
|
| 662 |
finally:
|
| 663 |
monkeypatch.delenv("LOG_LEVEL", raising=False)
|
| 664 |
bot._setup_logging() # восстановить дефолт INFO для остальных тестов
|
| 665 |
|
| 666 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 667 |
def test_setup_logging_uses_bot_logger_name_not_dunder_main():
|
| 668 |
# РЕГРЕССИЯ (аудит логирования): _setup_logging() раньше возвращал
|
| 669 |
# logging.getLogger(__name__) — "bot" при import bot (как в тестах), но
|
|
|
|
| 1869 |
bot.chat_state.pop(chat_id, None)
|
| 1870 |
|
| 1871 |
|
| 1872 |
+
@pytest.mark.parametrize(("from_user_id", "sender_chat_id", "expected"), [
|
| 1873 |
+
(555, None, 555),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1874 |
# Сообщение "от имени канала" — from_user отсутствует, но есть sender_chat.
|
| 1875 |
+
(None, -100777, -100777),
|
| 1876 |
+
# Ни from_user, ни sender_chat — сам chat.id, лишь бы не None (иначе
|
| 1877 |
+
# отправитель полностью нелимитирован).
|
| 1878 |
+
(None, None, -100999),
|
| 1879 |
+
])
|
| 1880 |
+
def test_rate_limit_key_for_message(from_user_id, sender_chat_id, expected):
|
| 1881 |
msg = SimpleNamespace(
|
| 1882 |
+
from_user=SimpleNamespace(id=from_user_id) if from_user_id is not None else None,
|
| 1883 |
+
sender_chat=SimpleNamespace(id=sender_chat_id) if sender_chat_id is not None else None,
|
| 1884 |
chat=SimpleNamespace(id=-100999),
|
| 1885 |
)
|
| 1886 |
+
assert bot._rate_limit_key_for_message(msg) == expected
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1887 |
|
| 1888 |
|
| 1889 |
def test_rate_limit_key_for_message_never_falls_through_to_none():
|
|
|
|
| 1897 |
assert key is not None and key != 0
|
| 1898 |
|
| 1899 |
|
| 1900 |
+
@pytest.mark.parametrize(("text", "is_private", "is_guest", "mentioned", "expected"), [
|
| 1901 |
+
("привет всем", False, False, False, True),
|
| 1902 |
+
("привет", True, False, False, False),
|
| 1903 |
+
("привет", False, True, False, False),
|
| 1904 |
+
("привет", False, False, True, False),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1905 |
# TikTok-ссылка обрабатывается всегда, даже без упоминания бота в группе.
|
| 1906 |
+
("гляньте https://www.tiktok.com/@user/video/123", False, False, False, False),
|
| 1907 |
+
])
|
| 1908 |
+
def test_should_only_record_passively(text, is_private, is_guest, mentioned, expected):
|
| 1909 |
msg = _FakeIncomingMessage(1)
|
| 1910 |
+
assert bot._should_only_record_passively(
|
| 1911 |
+
msg, text, is_private=is_private, is_guest=is_guest, mentioned=mentioned,
|
| 1912 |
+
) is expected
|
| 1913 |
|
| 1914 |
|
| 1915 |
def test_handle_message_core_known_route_outcome_logs_as_warning_not_exception(caplog):
|
tests/test_bot_tiktok.py
CHANGED
|
@@ -103,110 +103,56 @@ def test_tiktok_video_candidates_empty_when_nothing_available():
|
|
| 103 |
assert bot._tiktok_video_candidates({}) == []
|
| 104 |
|
| 105 |
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
assert bot._original_sound_label("en") == "Original sound"
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
def test_original_sound_label_strips_region_subtag():
|
| 123 |
-
# Telegram может прислать региональный вариант ("en-US", "pt-BR") — берём
|
| 124 |
-
# только первичный языковой подтег до дефиса.
|
| 125 |
-
assert bot._original_sound_label("en-US") == "Original sound"
|
| 126 |
-
assert bot._original_sound_label("pt-BR") == "Som original"
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
def test_original_sound_label_falls_back_to_english_for_unknown_code():
|
| 130 |
-
assert bot._original_sound_label("sw") == "Original sound"
|
| 131 |
-
assert bot._original_sound_label("xx-YY") == "Original sound"
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
def test_original_sound_label_falls_back_to_english_when_missing():
|
| 135 |
-
assert bot._original_sound_label(None) == "Original sound"
|
| 136 |
-
assert bot._original_sound_label("") == "Original sound"
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
def test_original_sound_label_case_insensitive():
|
| 140 |
-
assert bot._original_sound_label("RU") == "Оригинальный звук"
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
def test_original_sound_label_fallback_chain():
|
| 144 |
# Цепочка: язык отправителя → язык чата (/lang) → английский.
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
assert bot._tiktok_music_page_id(url) ==
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
def
|
| 180 |
-
|
| 181 |
-
# функция всё равно должна найти числовой ID (сама эвристика "сработает ли
|
| 182 |
-
# скачивание" находится не здесь, а в handle_tiktok_sound).
|
| 183 |
-
url = "https://www.tiktok.com/music/Blinding-Lights-6862178485109294850"
|
| 184 |
-
assert bot._tiktok_music_page_id(url) == "6862178485109294850"
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
def test_looks_like_resolved_tiktok_url_true_for_proper_video_url():
|
| 188 |
-
assert bot._looks_like_resolved_tiktok_url("https://www.tiktok.com/@someuser/video/7370000000000000001") is True
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
def test_looks_like_resolved_tiktok_url_true_for_proper_photo_url():
|
| 192 |
-
assert bot._looks_like_resolved_tiktok_url("https://www.tiktok.com/@someuser/photo/7370000000000000002") is True
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
def test_looks_like_resolved_tiktok_url_false_for_empty_username():
|
| 196 |
-
# Точно тот URL, ч��о реально ушёл в TikWM и получил 403 в реальном инциденте.
|
| 197 |
-
assert bot._looks_like_resolved_tiktok_url("https://www.tiktok.com/@/photo/7512093374153772309") is False
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
def test_looks_like_resolved_tiktok_url_false_for_bare_at_sign_without_post():
|
| 201 |
-
# Просто "@" где-то в строке (например голая страница профиля без поста, или
|
| 202 |
-
# случайное совпадение) — раньше проходило старую слабую проверку.
|
| 203 |
-
assert bot._looks_like_resolved_tiktok_url("https://www.tiktok.com/@someuser") is False
|
| 204 |
-
assert bot._looks_like_resolved_tiktok_url("https://www.tiktok.com/some-page?ref=@video") is False
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
def test_looks_like_resolved_tiktok_url_false_for_empty_or_none():
|
| 208 |
-
assert bot._looks_like_resolved_tiktok_url("") is False
|
| 209 |
-
assert bot._looks_like_resolved_tiktok_url(None) is False
|
| 210 |
|
| 211 |
|
| 212 |
def test_resolve_tiktok_short_falls_through_to_get_when_head_gives_malformed_url():
|
|
@@ -243,99 +189,65 @@ def test_resolve_tiktok_short_returns_get_result_even_if_still_malformed():
|
|
| 243 |
assert result == "https://www.tiktok.com/@/photo/999"
|
| 244 |
|
| 245 |
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
"https://tikwm.com/live0.mp4", "https://tikwm.com/photo1.jpg",
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
"
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
def test_looks_like_video_bytes_true_for_mp4_ftyp_signature():
|
| 274 |
# Реальная сигнатура начала MP4/MOV-контейнера: 4 байта размера бокса + "ftyp".
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
def test_looks_like_video_bytes_false_for_empty_bytes():
|
| 294 |
-
assert bot._looks_like_video_bytes(b"") is False
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
def test_chunk_tiktok_media_items_exact_multiple_of_ten():
|
| 298 |
-
items = list(range(20))
|
| 299 |
-
chunks = bot._chunk_tiktok_media_items(items)
|
| 300 |
-
assert [len(c) for c in chunks] == [10, 10]
|
| 301 |
-
assert sum(chunks, []) == items
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
def test_chunk_tiktok_media_items_avoids_trailing_single_item_at_eleven():
|
| 305 |
-
items = list(range(11))
|
| 306 |
-
chunks = bot._chunk_tiktok_media_items(items)
|
| 307 |
-
assert [len(c) for c in chunks] == [9, 2]
|
| 308 |
-
for c in chunks:
|
| 309 |
-
assert 2 <= len(c) <= 10
|
| 310 |
-
assert sum(chunks, []) == items
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
def test_chunk_tiktok_media_items_avoids_trailing_single_item_at_twenty_one():
|
| 314 |
-
items = list(range(21))
|
| 315 |
-
chunks = bot._chunk_tiktok_media_items(items)
|
| 316 |
-
assert [len(c) for c in chunks] == [10, 9, 2]
|
| 317 |
-
for c in chunks:
|
| 318 |
-
assert 2 <= len(c) <= 10
|
| 319 |
-
assert sum(chunks, []) == items
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
def test_chunk_tiktok_media_items_avoids_trailing_single_item_at_tiktok_max_thirty_one():
|
| 323 |
-
items = list(range(31))
|
| 324 |
-
chunks = bot._chunk_tiktok_media_items(items)
|
| 325 |
-
for c in chunks:
|
| 326 |
-
assert 2 <= len(c) <= 10
|
| 327 |
-
assert sum(chunks, []) == items
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
def test_chunk_tiktok_media_items_no_borrow_needed_at_thirty_five():
|
| 331 |
# Официальный максимум TikTok (35) кратен 10 с остатком 5 — переноса не требуется.
|
| 332 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 333 |
chunks = bot._chunk_tiktok_media_items(items)
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
|
|
|
| 339 |
|
| 340 |
|
| 341 |
def test_chunk_tiktok_media_items_single_item_not_split_further():
|
|
@@ -1621,11 +1533,6 @@ def test_tiktok_video_candidates_protocol_relative_url():
|
|
| 1621 |
assert candidates[0]["url"] == "https://cdn.tikwm.com/v.mp4"
|
| 1622 |
|
| 1623 |
|
| 1624 |
-
def test_looks_like_video_bytes_true_for_webm_ebml():
|
| 1625 |
-
# A7-9: WebM-слайд раньше принимался за фото.
|
| 1626 |
-
assert bot._looks_like_video_bytes(b"\x1a\x45\xdf\xa3" + b"\x00" * 16) is True
|
| 1627 |
-
|
| 1628 |
-
|
| 1629 |
def test_download_url_bin_honors_caller_cap():
|
| 1630 |
# Аудит A7-5: видео режем лимитом отправки (50МБ), а не общим 75МБ.
|
| 1631 |
mapping = {"http://8.8.8.8/v.mp4": _FakeDownloadResponse([b"123456789012345"])}
|
|
|
|
| 103 |
assert bot._tiktok_video_candidates({}) == []
|
| 104 |
|
| 105 |
|
| 106 |
+
@pytest.mark.parametrize(("user_lang", "chat_lang", "expected"), [
|
| 107 |
+
("ru", None, "Оригинальный звук"),
|
| 108 |
+
("uk", None, "Оригінальний звук"),
|
| 109 |
+
("be", None, "Арыгінальны гук"),
|
| 110 |
+
("en", None, "Original sound"),
|
| 111 |
+
# Telegram присылает региональный вариант ("en-US", "pt-BR") — только первичный подтег.
|
| 112 |
+
("en-US", None, "Original sound"),
|
| 113 |
+
("pt-BR", None, "Som original"),
|
| 114 |
+
("sw", None, "Original sound"),
|
| 115 |
+
("xx-YY", None, "Original sound"),
|
| 116 |
+
(None, None, "Original sound"),
|
| 117 |
+
("", None, "Original sound"),
|
| 118 |
+
("RU", None, "Оригинальный звук"),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 119 |
# Цепочка: язык отправителя → язык чата (/lang) → английский.
|
| 120 |
+
("de", "uk", "Originalton"),
|
| 121 |
+
(None, "uk", "Оригінальний звук"),
|
| 122 |
+
("sw", "kk", "Түпнұсқа дыбыс"),
|
| 123 |
+
("th", "kk", "เสียงต้นฉบับ"),
|
| 124 |
+
("sw", "xx", "Original sound"),
|
| 125 |
+
])
|
| 126 |
+
def test_original_sound_label(user_lang, chat_lang, expected):
|
| 127 |
+
assert bot._original_sound_label(user_lang, chat_lang) == expected
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
@pytest.mark.parametrize(("url", "expected"), [
|
| 131 |
+
("https://www.tiktok.com/music/original-sound-7666630127215823637", "7666630127215823637"),
|
| 132 |
+
# Реальный случай из логов — слаг на русском языке.
|
| 133 |
+
("https://www.tiktok.com/music/оригинальный-звук-7667114246303812385", "7667114246303812385"),
|
| 134 |
+
("https://www.tiktok.com/music/original-sound-7666630127215823637/", "7666630127215823637"),
|
| 135 |
+
("https://www.tiktok.com/@someuser/video/7370000000000000001", None),
|
| 136 |
+
("https://www.tiktok.com/@someuser/photo/7370000000000000002", None),
|
| 137 |
+
# Именованный трек со своим слагом — числовой ID всё равно находится.
|
| 138 |
+
("https://www.tiktok.com/music/Blinding-Lights-6862178485109294850", "6862178485109294850"),
|
| 139 |
+
])
|
| 140 |
+
def test_tiktok_music_page_id(url, expected):
|
| 141 |
+
assert bot._tiktok_music_page_id(url) == expected
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
@pytest.mark.parametrize(("url", "expected"), [
|
| 145 |
+
("https://www.tiktok.com/@someuser/video/7370000000000000001", True),
|
| 146 |
+
("https://www.tiktok.com/@someuser/photo/7370000000000000002", True),
|
| 147 |
+
# Тот URL с пустым юзернеймом, что реально ушёл в TikWM и получил 403.
|
| 148 |
+
("https://www.tiktok.com/@/photo/7512093374153772309", False),
|
| 149 |
+
("https://www.tiktok.com/@someuser", False),
|
| 150 |
+
("https://www.tiktok.com/some-page?ref=@video", False),
|
| 151 |
+
("", False),
|
| 152 |
+
(None, False),
|
| 153 |
+
])
|
| 154 |
+
def test_looks_like_resolved_tiktok_url(url, expected):
|
| 155 |
+
assert bot._looks_like_resolved_tiktok_url(url) is expected
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 156 |
|
| 157 |
|
| 158 |
def test_resolve_tiktok_short_falls_through_to_get_when_head_gives_malformed_url():
|
|
|
|
| 189 |
assert result == "https://www.tiktok.com/@/photo/999"
|
| 190 |
|
| 191 |
|
| 192 |
+
@pytest.mark.parametrize(("media_data", "images", "expected"), [
|
| 193 |
+
(
|
| 194 |
+
{"live_images": ["https://tikwm.com/live0.mp4", ""]},
|
| 195 |
+
["https://tikwm.com/photo0.jpg", "https://tikwm.com/photo1.jpg"],
|
| 196 |
+
["https://tikwm.com/live0.mp4", "https://tikwm.com/photo1.jpg"],
|
| 197 |
+
),
|
| 198 |
+
(
|
| 199 |
+
{},
|
| 200 |
+
["https://tikwm.com/photo0.jpg", "https://tikwm.com/photo1.jpg"],
|
| 201 |
+
["https://tikwm.com/photo0.jpg", "https://tikwm.com/photo1.jpg"],
|
| 202 |
+
),
|
| 203 |
+
(
|
| 204 |
+
{"live_images": ["https://tikwm.com/live0.mp4"]},
|
| 205 |
+
["https://tikwm.com/photo0.jpg", "https://tikwm.com/photo1.jpg"],
|
| 206 |
+
["https://tikwm.com/live0.mp4", "https://tikwm.com/photo1.jpg"],
|
| 207 |
+
),
|
| 208 |
+
(
|
| 209 |
+
{"live_images": "not-a-list"},
|
| 210 |
+
["https://tikwm.com/photo0.jpg"],
|
| 211 |
+
["https://tikwm.com/photo0.jpg"],
|
| 212 |
+
),
|
| 213 |
+
])
|
| 214 |
+
def test_slideshow_slide_urls(media_data, images, expected):
|
| 215 |
+
assert bot._slideshow_slide_urls(media_data, images) == expected
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
@pytest.mark.parametrize(("payload", "expected"), [
|
|
|
|
| 219 |
# Реальная сигнатура начала MP4/MOV-контейнера: 4 байта размера бокса + "ftyp".
|
| 220 |
+
(b"\x00\x00\x00\x18ftypmp42\x00\x00\x00\x00mp42isom", True),
|
| 221 |
+
# A7-9: WebM-слайд раньше принимался за фото.
|
| 222 |
+
(b"\x1a\x45\xdf\xa3" + b"\x00" * 16, True),
|
| 223 |
+
(b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00", False),
|
| 224 |
+
(b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR", False),
|
| 225 |
+
(b"\x00\x00\x00\x18fty", False),
|
| 226 |
+
(b"", False),
|
| 227 |
+
])
|
| 228 |
+
def test_looks_like_video_bytes(payload, expected):
|
| 229 |
+
assert bot._looks_like_video_bytes(payload) is expected
|
| 230 |
+
|
| 231 |
+
|
| 232 |
+
@pytest.mark.parametrize(("n_items", "expected"), [
|
| 233 |
+
(20, [10, 10]),
|
| 234 |
+
(11, [9, 2]),
|
| 235 |
+
(21, [10, 9, 2]),
|
| 236 |
+
# Хвост-одиночка на максимуме TikTok недопустим, точный расклад не фиксируем.
|
| 237 |
+
(31, None),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 238 |
# Официальный максимум TikTok (35) кратен 10 с остатком 5 — переноса не требуется.
|
| 239 |
+
(35, [10, 10, 10, 5]),
|
| 240 |
+
(0, []),
|
| 241 |
+
])
|
| 242 |
+
def test_chunk_tiktok_media_items(n_items, expected):
|
| 243 |
+
items = list(range(n_items))
|
| 244 |
chunks = bot._chunk_tiktok_media_items(items)
|
| 245 |
+
if expected is not None:
|
| 246 |
+
assert [len(c) for c in chunks] == expected
|
| 247 |
+
else:
|
| 248 |
+
for c in chunks:
|
| 249 |
+
assert 2 <= len(c) <= 10
|
| 250 |
+
assert sum(chunks, []) == items
|
| 251 |
|
| 252 |
|
| 253 |
def test_chunk_tiktok_media_items_single_item_not_split_further():
|
|
|
|
| 1533 |
assert candidates[0]["url"] == "https://cdn.tikwm.com/v.mp4"
|
| 1534 |
|
| 1535 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1536 |
def test_download_url_bin_honors_caller_cap():
|
| 1537 |
# Аудит A7-5: видео режем лимитом отправки (50МБ), а не общим 75МБ.
|
| 1538 |
mapping = {"http://8.8.8.8/v.mp4": _FakeDownloadResponse([b"123456789012345"])}
|
tests/test_bot_transport.py
CHANGED
|
@@ -26,21 +26,16 @@ def test_circuit_breaker_starts_closed():
|
|
| 26 |
assert breaker.is_down(time.monotonic()) is False
|
| 27 |
|
| 28 |
|
| 29 |
-
|
|
|
|
| 30 |
breaker = bot._TelegramProxyCircuitBreaker(cooldown_sec=20.0, trip_threshold=3)
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
breaker.note_failure()
|
| 39 |
-
breaker.note_failure()
|
| 40 |
-
tripped = breaker.note_failure()
|
| 41 |
-
assert tripped is True
|
| 42 |
-
breaker.trip()
|
| 43 |
-
assert breaker.is_down(time.monotonic()) is True
|
| 44 |
|
| 45 |
|
| 46 |
def test_circuit_breaker_success_resets_consecutive_failures():
|
|
@@ -152,40 +147,26 @@ def test_rotate_telegram_proxy_noop_with_single_candidate():
|
|
| 152 |
bot._telegram_proxy_idx = original_idx
|
| 153 |
|
| 154 |
|
| 155 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 156 |
original_candidates = bot._TELEGRAM_PROXY_CANDIDATES
|
| 157 |
original_idx = bot._telegram_proxy_idx
|
| 158 |
original_base_url = bot.TELEGRAM_API_BASE_URL
|
| 159 |
original_bot = bot.bot
|
| 160 |
bot._TELEGRAM_PROXY_CANDIDATES = ["https://primary.example.com", "https://fallback.example.com"]
|
| 161 |
-
bot._telegram_proxy_idx =
|
| 162 |
-
bot.TELEGRAM_API_BASE_URL = "https://primary.example.com"
|
| 163 |
bot.bot = None # без реального aiogram Bot — проверяем только URL-переключение
|
| 164 |
try:
|
| 165 |
switched = asyncio.run(bot._rotate_telegram_proxy())
|
| 166 |
-
assert switched is
|
| 167 |
-
assert bot.
|
| 168 |
-
assert bot.
|
| 169 |
-
finally:
|
| 170 |
-
bot._TELEGRAM_PROXY_CANDIDATES = original_candidates
|
| 171 |
-
bot._telegram_proxy_idx = original_idx
|
| 172 |
-
bot.TELEGRAM_API_BASE_URL = original_base_url
|
| 173 |
-
bot.bot = original_bot
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
def test_rotate_telegram_proxy_reports_lap_done_after_full_cycle():
|
| 177 |
-
original_candidates = bot._TELEGRAM_PROXY_CANDIDATES
|
| 178 |
-
original_idx = bot._telegram_proxy_idx
|
| 179 |
-
original_base_url = bot.TELEGRAM_API_BASE_URL
|
| 180 |
-
original_bot = bot.bot
|
| 181 |
-
bot._TELEGRAM_PROXY_CANDIDATES = ["https://primary.example.com", "https://fallback.example.com"]
|
| 182 |
-
bot._telegram_proxy_idx = 1 # уже на резервном — следующий поворот вернёт на primary (idx 0)
|
| 183 |
-
bot.TELEGRAM_API_BASE_URL = "https://fallback.example.com"
|
| 184 |
-
bot.bot = None
|
| 185 |
-
try:
|
| 186 |
-
switched = asyncio.run(bot._rotate_telegram_proxy())
|
| 187 |
-
assert switched is False # круг замкнулся — пора паузу включать
|
| 188 |
-
assert bot._telegram_proxy_idx == 0
|
| 189 |
finally:
|
| 190 |
bot._TELEGRAM_PROXY_CANDIDATES = original_candidates
|
| 191 |
bot._telegram_proxy_idx = original_idx
|
|
|
|
| 26 |
assert breaker.is_down(time.monotonic()) is False
|
| 27 |
|
| 28 |
|
| 29 |
+
@pytest.mark.parametrize(("failures", "tripped"), [(2, False), (3, True)])
|
| 30 |
+
def test_circuit_breaker_trip_threshold(failures, tripped):
|
| 31 |
breaker = bot._TelegramProxyCircuitBreaker(cooldown_sec=20.0, trip_threshold=3)
|
| 32 |
+
result = False
|
| 33 |
+
for _ in range(failures):
|
| 34 |
+
result = breaker.note_failure()
|
| 35 |
+
assert result is tripped
|
| 36 |
+
if tripped:
|
| 37 |
+
breaker.trip()
|
| 38 |
+
assert breaker.is_down(time.monotonic()) is tripped
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
|
| 40 |
|
| 41 |
def test_circuit_breaker_success_resets_consecutive_failures():
|
|
|
|
| 147 |
bot._telegram_proxy_idx = original_idx
|
| 148 |
|
| 149 |
|
| 150 |
+
@pytest.mark.parametrize(("start_idx", "expected_idx", "expected_switched", "expected_url"), [
|
| 151 |
+
# Ещё не замкнули круг — есть смысл пробовать сразу.
|
| 152 |
+
(0, 1, True, "https://fallback.example.com"),
|
| 153 |
+
# Уже на резервном — поворот вернёт на primary, круг замкнулся, пора пауза.
|
| 154 |
+
(1, 0, False, "https://primary.example.com"),
|
| 155 |
+
])
|
| 156 |
+
def test_rotate_telegram_proxy_advances_and_reports_lap(start_idx, expected_idx, expected_switched, expected_url):
|
| 157 |
original_candidates = bot._TELEGRAM_PROXY_CANDIDATES
|
| 158 |
original_idx = bot._telegram_proxy_idx
|
| 159 |
original_base_url = bot.TELEGRAM_API_BASE_URL
|
| 160 |
original_bot = bot.bot
|
| 161 |
bot._TELEGRAM_PROXY_CANDIDATES = ["https://primary.example.com", "https://fallback.example.com"]
|
| 162 |
+
bot._telegram_proxy_idx = start_idx
|
| 163 |
+
bot.TELEGRAM_API_BASE_URL = "https://primary.example.com" if start_idx == 0 else "https://fallback.example.com"
|
| 164 |
bot.bot = None # без реального aiogram Bot — проверяем только URL-переключение
|
| 165 |
try:
|
| 166 |
switched = asyncio.run(bot._rotate_telegram_proxy())
|
| 167 |
+
assert switched is expected_switched
|
| 168 |
+
assert bot._telegram_proxy_idx == expected_idx
|
| 169 |
+
assert bot.TELEGRAM_API_BASE_URL == expected_url
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 170 |
finally:
|
| 171 |
bot._TELEGRAM_PROXY_CANDIDATES = original_candidates
|
| 172 |
bot._telegram_proxy_idx = original_idx
|
tests/test_lumen_formatting.py
CHANGED
|
@@ -14,10 +14,8 @@ Telegram/Gemini/OpenRouter/рантайм-состояния бота, поэт
|
|
| 14 |
Запуск:
|
| 15 |
pytest test_lumen_formatting.py -v
|
| 16 |
"""
|
| 17 |
-
import inspect
|
| 18 |
-
|
| 19 |
import lumen_formatting
|
| 20 |
-
import
|
| 21 |
|
| 22 |
|
| 23 |
|
|
@@ -37,20 +35,15 @@ def test_truncate_html_to_fit_keeps_short_text_and_cuts_at_source_boundary():
|
|
| 37 |
|
| 38 |
# ─────────────────────────── _md_to_html ───────────────────────────
|
| 39 |
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
assert lumen_formatting._md_to_html("*italic*") == "<i>italic</i>"
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
def test_md_to_html_strikethrough():
|
| 53 |
-
assert lumen_formatting._md_to_html("~~strike~~") == "<s>strike</s>"
|
| 54 |
|
| 55 |
|
| 56 |
def test_md_to_html_escapes_raw_html():
|
|
@@ -64,10 +57,7 @@ def test_md_to_html_inline_code():
|
|
| 64 |
|
| 65 |
|
| 66 |
def test_md_to_html_code_block():
|
| 67 |
-
# Язык фенса
|
| 68 |
-
# в Telegram) — см. test_md_to_html_code_block_preserves_language_for_syntax_
|
| 69 |
-
# highlighting ниже; без языка поведение как раньше (test_md_to_html_code_
|
| 70 |
-
# block_without_language_unchanged).
|
| 71 |
assert lumen_formatting._md_to_html("```python\nprint(1)\n```") == '<pre><code class="language-python">print(1)</code></pre>'
|
| 72 |
|
| 73 |
|
|
@@ -112,36 +102,30 @@ def test_md_to_html_still_escapes_ordinary_comparison_operators():
|
|
| 112 |
assert lumen_formatting._md_to_html("сравнение: 5 < 10") == "сравнение: 5 < 10"
|
| 113 |
|
| 114 |
|
| 115 |
-
|
| 116 |
-
# Регрессия н
|
| 117 |
-
#
|
| 118 |
-
|
| 119 |
-
text = (
|
| 120 |
"| Аспект | React | Vue |\n"
|
| 121 |
"|--------|-------|-----|\n"
|
| 122 |
"| Кривая обучения | Высокая | Низкая |\n"
|
| 123 |
-
"| Сообщество | Огромное | Среднее |"
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
assert "|" not in result
|
| 127 |
-
assert "<b>Аспект:</b> Кривая обучения" in result
|
| 128 |
-
assert "<b>React:</b> Высокая" in result
|
| 129 |
-
assert "<b>Vue:</b> Низкая" in result
|
| 130 |
-
assert result.count("•") == 2
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
def test_md_to_html_table_without_outer_pipes_still_converted():
|
| 134 |
# Некоторые модели пишут таблицы без внешних "|" по краям строки.
|
| 135 |
-
|
| 136 |
"Название | Цена\n"
|
| 137 |
"---|---\n"
|
| 138 |
"Кофе | 150\n"
|
| 139 |
-
"Чай | 100"
|
| 140 |
-
|
|
|
|
|
|
|
|
|
|
| 141 |
result = lumen_formatting._md_to_html(text)
|
| 142 |
assert "|" not in result
|
| 143 |
-
assert
|
| 144 |
-
assert "
|
| 145 |
|
| 146 |
|
| 147 |
def test_md_to_html_does_not_touch_pipes_inside_code_block():
|
|
@@ -166,22 +150,24 @@ def test_md_to_html_no_false_positive_on_plain_text_with_dashes():
|
|
| 166 |
# выдала "\[ S = \pi r^{2}, \]" и "\(x^{2}+y^{2}=r^{2}\)" вместо юникода, несмотря на
|
| 167 |
# явный запрет LaTeX в system_prompt.py.
|
| 168 |
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
|
|
|
|
|
|
| 175 |
|
| 176 |
-
def test_scrub_latex_converts_paren_delimiters():
|
| 177 |
-
result = lumen_formatting._scrub_latex(r"формула \(x^{2}+y^{2}=r^{2}\)")
|
| 178 |
-
assert "\\(" not in result and "\\)" not in result
|
| 179 |
-
assert "x²+y²=r²" in result
|
| 180 |
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 185 |
|
| 186 |
|
| 187 |
def test_scrub_latex_converts_common_symbols():
|
|
@@ -191,10 +177,6 @@ def test_scrub_latex_converts_common_symbols():
|
|
| 191 |
assert "×" in result and "±" in result and "≤" in result and "≥" in result and "∞" in result
|
| 192 |
|
| 193 |
|
| 194 |
-
def test_scrub_latex_noop_when_no_backslash_or_dollar():
|
| 195 |
-
assert lumen_formatting._scrub_latex("обычный текст без формул") == "обычный текст без формул"
|
| 196 |
-
|
| 197 |
-
|
| 198 |
def test_scrub_latex_does_not_confuse_currency_with_math_delimiters():
|
| 199 |
# РЕГРЕССИЯ, найденная при code-review (25 июля 2026): первая версия скрубера
|
| 200 |
# обрабатывала и одиночный "$...$" как инлайн-LaTeX. Если в одном сообщении
|
|
@@ -204,17 +186,13 @@ def test_scrub_latex_does_not_confuse_currency_with_math_delimiters():
|
|
| 204 |
# ("цена 100, а формула x²$ рядом"). Одиночный "$" теперь не обрабатывается
|
| 205 |
# вообще — только "$$...$$". Сами доллары остаются нетронутыми в обоих случаях;
|
| 206 |
# "x^2" внутри всё равно аккуратно превращается в "x²" — это отдельная, не
|
| 207 |
-
# завязанная на "$"-разделители замена (см.
|
| 208 |
-
# здесь, и вне контекста "$".
|
| 209 |
assert lumen_formatting._scrub_latex("цена $100, а формула $x^2$ рядом") == "цена $100, а формула $x²$ рядом"
|
| 210 |
assert lumen_formatting._scrub_latex("первый вариант — $50, второй — $100") == "первый вариант — $50, второй — $100"
|
| 211 |
assert lumen_formatting._scrub_latex("стоимость: $100. Итого: $200.") == "стоимость: $100. Итого: $200."
|
| 212 |
|
| 213 |
|
| 214 |
-
def test_scrub_latex_still_converts_double_dollar_display_math():
|
| 215 |
-
assert lumen_formatting._scrub_latex("$$x^2 + y^2$$") == "x² + y²"
|
| 216 |
-
|
| 217 |
-
|
| 218 |
def test_scrub_latex_order_sensitive_replacements_dont_corrupt_each_other():
|
| 219 |
# РЕГРЕССИЯ НА БУДУЩЕЕ: _LATEX_SYMBOL_MAP — это plain str.replace() в порядке
|
| 220 |
# вставки словаря, а не regex. "\le" — подстрока "\leq", "\in" — подстрока
|
|
@@ -244,13 +222,13 @@ def test_scrub_latex_protected_inside_code_blocks_via_full_pipeline():
|
|
| 244 |
# `code`/таблицы, но раньше НЕ трогал обычные markdown-списки — они уходили в
|
| 245 |
# Telegram буквально с "-"/"*" в начале строки.
|
| 246 |
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
def
|
| 253 |
-
assert lumen_formatting._normalize_bullet_markers(
|
| 254 |
|
| 255 |
|
| 256 |
def test_normalize_bullet_markers_does_not_touch_bold_at_line_start():
|
|
@@ -283,45 +261,37 @@ def test_split_inline_bullets_ignores_short_prose():
|
|
| 283 |
assert lumen_formatting._split_inline_bullets("На выбор чай • кофе.") == "На выбор чай • кофе."
|
| 284 |
|
| 285 |
|
| 286 |
-
|
| 287 |
-
# Прод 26.09.2026: ровно 3 пункта (2 разделителя) — самый частый живой случай
|
| 288 |
-
|
| 289 |
-
text = (
|
| 290 |
"• Рассеяние Рэлея: мелкие частицы в атмосфере рассеивают коротковолновый свет. "
|
| 291 |
"• Солнечный спектр: солнце излучает больше синего света днём и ночью. "
|
| 292 |
-
"• Отсутствие поглощения: газы атмосферы почти не поглощают синий свет."
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
assert all(line.startswith("• ") for line in lines)
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
def test_split_inline_bullets_splits_glued_without_spaces():
|
| 301 |
-
# Прод 05.10.2026: скриншот — модель склеила список способностей вообще без
|
| 302 |
-
# пробелов вокруг "•", старая сетка на " • " его не видела.
|
| 303 |
-
text = (
|
| 304 |
"• Отвечать на вопросы, вести беседу.• Выполнять веб-поиск и использовать полученные данные. "
|
| 305 |
-
"•Читать открытые веб-страницы и анализировать их содержание.• Анализировать присланные фото, видео и документы."
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
lines = lumen_formatting._split_inline_bullets(text).split("\n")
|
| 309 |
-
assert len(lines) == 4
|
| 310 |
-
assert all(line.startswith("• ") for line in lines)
|
| 311 |
-
assert "" not in lines
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
def test_split_inline_bullets_splits_middot_variant():
|
| 315 |
# Та же склейка маркером "·": другой символ, та же каша для читателя.
|
| 316 |
-
|
| 317 |
"Итоги такие: первый пункт с подробностями для длины строки и смыслом · второй пункт с подробностями "
|
| 318 |
-
"для длины строки и смыслом · третий пункт с подробностями для длины строки и смыслом · четвёртый пункт."
|
| 319 |
-
|
|
|
|
|
|
|
|
|
|
| 320 |
assert len(text) >= 200
|
| 321 |
lines = lumen_formatting._split_inline_bullets(text).split("\n")
|
| 322 |
-
assert len(lines) ==
|
| 323 |
-
assert
|
| 324 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 325 |
|
| 326 |
|
| 327 |
def test_md_to_html_splits_inline_bullets_end_to_end():
|
|
@@ -331,23 +301,27 @@ def test_md_to_html_splits_inline_bullets_end_to_end():
|
|
| 331 |
assert result.count("\n• ") == 3
|
| 332 |
|
| 333 |
|
| 334 |
-
|
| 335 |
# Прод 25.09.2026: модель написала "1. ... 2. ... 3. ..." одним абзацем.
|
| 336 |
-
|
| 337 |
"1. Рэлеевское рассеяние — молекулы воздуха рассеивают солнечный свет. "
|
| 338 |
"2. Зависимость от длины волны — короткие волны рассеиваются сильнее. "
|
| 339 |
-
"3. Восприятие глаза — глаз чувствительнее к синему цвету неба."
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
lines = lumen_formatting._split_inline_numbered(text).split("\n")
|
| 349 |
-
|
| 350 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 351 |
|
| 352 |
|
| 353 |
def test_split_inline_numbered_ignores_prose_and_versions():
|
|
@@ -421,15 +395,6 @@ def test_render_paths_differ_only_for_headings():
|
|
| 421 |
assert lumen_formatting._md_to_rich_html("## Title") == "<h3>Title</h3>"
|
| 422 |
|
| 423 |
|
| 424 |
-
def test_streaming_history_uses_summarizing_trim():
|
| 425 |
-
# Стриминговый путь резал историю голым del, из-за чего саммаризация старого
|
| 426 |
-
# работала только в нестриминговых ветках (аудит 26.09.2026).
|
| 427 |
-
src = inspect.getsource(lumen_streaming._run_streaming_reply)
|
| 428 |
-
code_lines = [ln for ln in src.splitlines() if not ln.strip().startswith("#")]
|
| 429 |
-
assert any("_trim_history" in ln for ln in code_lines)
|
| 430 |
-
assert not any("hist[:-" in ln for ln in code_lines), "в стриминге остался молчаливый срез истории"
|
| 431 |
-
|
| 432 |
-
|
| 433 |
def test_md_to_rich_html_splits_three_bullets_prod_movies():
|
| 434 |
# Прод 26.09.2026: ответ про мелодрамы — 3 пункта склеились, rich-путь молчал.
|
| 435 |
text = (
|
|
@@ -455,13 +420,13 @@ def test_md_to_html_full_pipeline_converts_bullet_list_with_bold():
|
|
| 455 |
# модели OpenRouter) регулярно их всё равно пишут — раньше "###" уходило в
|
| 456 |
# Telegram буквально, без единой защитной сетки (в отличие от таблиц/списков).
|
| 457 |
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
assert lumen_formatting._normalize_headers(
|
| 465 |
|
| 466 |
|
| 467 |
def test_normalize_headers_does_not_touch_hash_mid_line():
|
|
@@ -504,20 +469,16 @@ def test_md_to_html_header_hash_inside_code_block_untouched():
|
|
| 504 |
|
| 505 |
# ─────────────────── spoiler-тег: защитная сетка (тот же принцип, что <u>) ───────────────────
|
| 506 |
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
def
|
| 512 |
-
assert lumen_formatting._md_to_html(
|
| 513 |
|
| 514 |
|
| 515 |
# ─────────────────── подсветка синтакси��а: язык из ```fence сохраняется ───────────────────
|
| 516 |
-
|
| 517 |
-
def test_md_to_html_code_block_preserves_language_for_syntax_highlighting():
|
| 518 |
-
result = lumen_formatting._md_to_html("```python\nprint(1)\n```")
|
| 519 |
-
assert result == '<pre><code class="language-python">print(1)</code></pre>'
|
| 520 |
-
|
| 521 |
|
| 522 |
def test_md_to_html_code_block_without_language_unchanged():
|
| 523 |
assert lumen_formatting._md_to_html("```\nprint(1)\n```") == "<pre>print(1)</pre>"
|
|
@@ -525,13 +486,12 @@ def test_md_to_html_code_block_without_language_unchanged():
|
|
| 525 |
|
| 526 |
# ─────────────────── markdown-цитаты "> " → <blockquote> ───────────────────
|
| 527 |
|
| 528 |
-
|
| 529 |
-
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
def
|
| 533 |
-
|
| 534 |
-
assert result == "<blockquote>первая строка\nвторая строка</blockquote>"
|
| 535 |
|
| 536 |
|
| 537 |
def test_md_to_html_blockquote_markdown_inside_still_converts():
|
|
@@ -603,16 +563,29 @@ def test_rich_non_table_pipes_left_alone():
|
|
| 603 |
assert lumen_formatting._md_to_rich_html("a | b") == "a | b"
|
| 604 |
|
| 605 |
|
| 606 |
-
|
| 607 |
-
|
| 608 |
-
|
| 609 |
-
|
| 610 |
-
|
| 611 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 612 |
# В отличие от _md_to_html (юникод-замена), рич-путь отдаёт LaTeX сырым —
|
| 613 |
# рендерит сервер Telegram.
|
| 614 |
-
assert lumen_formatting._md_to_rich_html(
|
| 615 |
-
assert lumen_formatting._md_to_rich_html("формула $$E=mc^2$$ конец") == "формула <tg-math-block>E=mc^2</tg-math-block> конец"
|
| 616 |
|
| 617 |
|
| 618 |
def test_rich_keeps_bold_links_code_quotes_and_escape():
|
|
@@ -628,20 +601,6 @@ def test_rich_math_inside_code_stays_code():
|
|
| 628 |
assert lumen_formatting._md_to_rich_html("`$x$`") == "<code>$x$</code>"
|
| 629 |
|
| 630 |
|
| 631 |
-
def test_rich_bracket_math_forms():
|
| 632 |
-
# Скобочные формы LaTeX (реальный кейс nemotron): \[...\] — блочная,
|
| 633 |
-
# \(...\) — инлайн. Извлекаются раньше $ во избежание конфликтов.
|
| 634 |
-
assert lumen_formatting._md_to_rich_html("смотри \\[S = \\pi r^{2}\\] конец") == "смотри <tg-math-block>S = \\pi r^{2}</tg-math-block> конец"
|
| 635 |
-
assert lumen_formatting._md_to_rich_html("значение \\(x\\) тут") == "значение <tg-math>x</tg-math> тут"
|
| 636 |
-
|
| 637 |
-
|
| 638 |
-
def test_rich_headings_tolerate_indent_and_deep_levels():
|
| 639 |
-
# Тот же допуск, что у легаси-нормализации: ведущие пробелы и 1–6 решёток.
|
| 640 |
-
assert lumen_formatting._md_to_rich_html(" ## Заголовок") == "<h3>Заголовок</h3>"
|
| 641 |
-
assert lumen_formatting._md_to_rich_html("# Топ") == "<h2>Топ</h2>"
|
| 642 |
-
assert lumen_formatting._md_to_rich_html("#### Глубокий") == "<h4>Глубокий</h4>"
|
| 643 |
-
|
| 644 |
-
|
| 645 |
def test_br_tag_becomes_line_break_in_both_paths():
|
| 646 |
# Прод-кейс 17.09.2026: модель пишет "<br>" внутри ячеек таблиц — без
|
| 647 |
# обработки долетает до escape и светится буквально ("...сгор.<br>Pro Max").
|
|
|
|
| 14 |
Запуск:
|
| 15 |
pytest test_lumen_formatting.py -v
|
| 16 |
"""
|
|
|
|
|
|
|
| 17 |
import lumen_formatting
|
| 18 |
+
import pytest
|
| 19 |
|
| 20 |
|
| 21 |
|
|
|
|
| 35 |
|
| 36 |
# ─────────────────────────── _md_to_html ───────────────────────────
|
| 37 |
|
| 38 |
+
@pytest.mark.parametrize(("source", "expected"), [
|
| 39 |
+
("", ""),
|
| 40 |
+
("**bold**", "<b>bold</b>"),
|
| 41 |
+
("*italic*", "<i>italic</i>"),
|
| 42 |
+
("~~strike~~", "<s>strike</s>"),
|
| 43 |
+
("`code`", "<code>code</code>"),
|
| 44 |
+
])
|
| 45 |
+
def test_md_to_html_basic_markup(source, expected):
|
| 46 |
+
assert lumen_formatting._md_to_html(source) == expected
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
|
| 48 |
|
| 49 |
def test_md_to_html_escapes_raw_html():
|
|
|
|
| 57 |
|
| 58 |
|
| 59 |
def test_md_to_html_code_block():
|
| 60 |
+
# Язык фенса сохраняется как class="language-x" для подсветки в Telegram.
|
|
|
|
|
|
|
|
|
|
| 61 |
assert lumen_formatting._md_to_html("```python\nprint(1)\n```") == '<pre><code class="language-python">print(1)</code></pre>'
|
| 62 |
|
| 63 |
|
|
|
|
| 102 |
assert lumen_formatting._md_to_html("сравнение: 5 < 10") == "сравнение: 5 < 10"
|
| 103 |
|
| 104 |
|
| 105 |
+
@pytest.mark.parametrize(("text", "present"), [
|
| 106 |
+
# Регрессия: Telegram не рендерит markdown-таблицы ни в каком режиме —
|
| 107 |
+
# пользователь видел сырой текст с "|" и "---" (подтверждено скриншотами).
|
| 108 |
+
(
|
|
|
|
| 109 |
"| Аспект | React | Vue |\n"
|
| 110 |
"|--------|-------|-----|\n"
|
| 111 |
"| Кривая обучения | Высокая | Низкая |\n"
|
| 112 |
+
"| Сообщество | Огромное | Среднее |",
|
| 113 |
+
("<b>Аспект:</b> Кривая обучения", "<b>React:</b> Высокая", "<b>Vue:</b> Низкая"),
|
| 114 |
+
),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
# Некоторые модели пишут таблицы без внешних "|" по краям строки.
|
| 116 |
+
(
|
| 117 |
"Название | Цена\n"
|
| 118 |
"---|---\n"
|
| 119 |
"Кофе | 150\n"
|
| 120 |
+
"Чай | 100",
|
| 121 |
+
("<b>Название:</b> Кофе", "<b>Цена:</b> 150"),
|
| 122 |
+
),
|
| 123 |
+
])
|
| 124 |
+
def test_md_to_html_converts_markdown_tables_to_bullet_lists(text, present):
|
| 125 |
result = lumen_formatting._md_to_html(text)
|
| 126 |
assert "|" not in result
|
| 127 |
+
assert all(p in result for p in present)
|
| 128 |
+
assert result.count("•") == 2
|
| 129 |
|
| 130 |
|
| 131 |
def test_md_to_html_does_not_touch_pipes_inside_code_block():
|
|
|
|
| 150 |
# выдала "\[ S = \pi r^{2}, \]" и "\(x^{2}+y^{2}=r^{2}\)" вместо юникода, несмотря на
|
| 151 |
# явный запрет LaTeX в system_prompt.py.
|
| 152 |
|
| 153 |
+
@pytest.mark.parametrize(("source", "gone", "present"), [
|
| 154 |
+
(r"Площадь: \[ S = \pi r^{2} \]", ("\\[", "\\]"), ("π", "r²")),
|
| 155 |
+
(r"формула \(x^{2}+y^{2}=r^{2}\)", ("\\(", "\\)"), ("x²+y²=r²",)),
|
| 156 |
+
])
|
| 157 |
+
def test_scrub_latex_converts_delimited_math(source, gone, present):
|
| 158 |
+
result = lumen_formatting._scrub_latex(source)
|
| 159 |
+
assert all(g not in result for g in gone)
|
| 160 |
+
assert all(p in result for p in present)
|
| 161 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
|
| 163 |
+
@pytest.mark.parametrize(("source", "expected"), [
|
| 164 |
+
(r"\frac{1}{2}", "1/2"),
|
| 165 |
+
(r"\sqrt{16}", "√16"),
|
| 166 |
+
("обычный текст без формул", "обычный текст без формул"),
|
| 167 |
+
("$$x^2 + y^2$$", "x² + y²"),
|
| 168 |
+
])
|
| 169 |
+
def test_scrub_latex_exact_replacements(source, expected):
|
| 170 |
+
assert lumen_formatting._scrub_latex(source) == expected
|
| 171 |
|
| 172 |
|
| 173 |
def test_scrub_latex_converts_common_symbols():
|
|
|
|
| 177 |
assert "×" in result and "±" in result and "≤" in result and "≥" in result and "∞" in result
|
| 178 |
|
| 179 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
def test_scrub_latex_does_not_confuse_currency_with_math_delimiters():
|
| 181 |
# РЕГРЕССИЯ, найденная при code-review (25 июля 2026): первая версия скрубера
|
| 182 |
# обрабатывала и одиночный "$...$" как инлайн-LaTeX. Если в одном сообщении
|
|
|
|
| 186 |
# ("цена 100, а формула x²$ рядом"). Одиночный "$" теперь не обрабатывается
|
| 187 |
# вообще — только "$$...$$". Сами доллары остаются нетронутыми в обоих случаях;
|
| 188 |
# "x^2" внутри всё равно аккуратно превращается в "x²" — это отдельная, не
|
| 189 |
+
# завязанная на "$"-разделители замена (см. test_scrub_latex_exact_replacements),
|
| 190 |
+
# она безвредна и здесь, и вне контекста "$".
|
| 191 |
assert lumen_formatting._scrub_latex("цена $100, а формула $x^2$ рядом") == "цена $100, а формула $x²$ рядом"
|
| 192 |
assert lumen_formatting._scrub_latex("первый вариант — $50, второй — $100") == "первый вариант — $50, второй — $100"
|
| 193 |
assert lumen_formatting._scrub_latex("стоимость: $100. Итого: $200.") == "стоимость: $100. Итого: $200."
|
| 194 |
|
| 195 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 196 |
def test_scrub_latex_order_sensitive_replacements_dont_corrupt_each_other():
|
| 197 |
# РЕГРЕССИЯ НА БУДУЩЕЕ: _LATEX_SYMBOL_MAP — это plain str.replace() в порядке
|
| 198 |
# вставки словаря, а не regex. "\le" — подстрока "\leq", "\in" — подстрока
|
|
|
|
| 222 |
# `code`/таблицы, но раньше НЕ трогал обычные markdown-списки — они уходили в
|
| 223 |
# Telegram буквально с "-"/"*" в начале строки.
|
| 224 |
|
| 225 |
+
@pytest.mark.parametrize(("source", "expected"), [
|
| 226 |
+
("- Пункт один\n- Пункт два", "• Пункт один\n• Пункт два"),
|
| 227 |
+
("* Пункт один\n* Пункт два", "• Пункт один\n• Пункт два"),
|
| 228 |
+
(" - вложенный пункт", " • вложенный пункт"),
|
| 229 |
+
])
|
| 230 |
+
def test_normalize_bullet_markers(source, expected):
|
| 231 |
+
assert lumen_formatting._normalize_bullet_markers(source) == expected
|
| 232 |
|
| 233 |
|
| 234 |
def test_normalize_bullet_markers_does_not_touch_bold_at_line_start():
|
|
|
|
| 261 |
assert lumen_formatting._split_inline_bullets("На выбор чай • кофе.") == "На выбор чай • кофе."
|
| 262 |
|
| 263 |
|
| 264 |
+
@pytest.mark.parametrize(("text", "n_lines", "intro"), [
|
| 265 |
+
# Прод 26.09.2026: ровно 3 пункта (2 разделителя) — самый частый живой случай.
|
| 266 |
+
(
|
|
|
|
| 267 |
"• Рассеяние Рэлея: мелкие частицы в атмосфере рассеивают коротковолновый свет. "
|
| 268 |
"• Солнечный спектр: солнце излучает больше синего света днём и ночью. "
|
| 269 |
+
"• Отсутствие поглощения: газы атмосферы почти не поглощают синий свет.",
|
| 270 |
+
3, None,
|
| 271 |
+
),
|
| 272 |
+
# Прод 05.10.2026: склейка вообще без пробелов вокруг "•".
|
| 273 |
+
(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 274 |
"• Отвечать на вопросы, вести беседу.• Выполнять веб-поиск и использовать полученные данные. "
|
| 275 |
+
"•Читать открытые веб-страницы и анализировать их содержание.• Анализировать присланные фото, видео и документы.",
|
| 276 |
+
4, None,
|
| 277 |
+
),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 278 |
# Та же склейка маркером "·": другой символ, та же каша для читателя.
|
| 279 |
+
(
|
| 280 |
"Итоги такие: первый пункт с подробностями для длины строки и смыслом · второй пункт с подробностями "
|
| 281 |
+
"для длины строки и смыслом · третий пункт с подробностями для длины строки и смыслом · четвёртый пункт.",
|
| 282 |
+
4, "Итоги такие: ",
|
| 283 |
+
),
|
| 284 |
+
])
|
| 285 |
+
def test_split_inline_bullets_splits_prod_answers(text, n_lines, intro):
|
| 286 |
assert len(text) >= 200
|
| 287 |
lines = lumen_formatting._split_inline_bullets(text).split("\n")
|
| 288 |
+
assert len(lines) == n_lines
|
| 289 |
+
assert "" not in lines
|
| 290 |
+
if intro is None:
|
| 291 |
+
assert all(line.startswith("• ") for line in lines)
|
| 292 |
+
else:
|
| 293 |
+
assert lines[0].startswith(intro)
|
| 294 |
+
assert all(line.startswith("• ") for line in lines[1:])
|
| 295 |
|
| 296 |
|
| 297 |
def test_md_to_html_splits_inline_bullets_end_to_end():
|
|
|
|
| 301 |
assert result.count("\n• ") == 3
|
| 302 |
|
| 303 |
|
| 304 |
+
@pytest.mark.parametrize(("text", "intro"), [
|
| 305 |
# Прод 25.09.2026: модель написала "1. ... 2. ... 3. ..." одним абзацем.
|
| 306 |
+
(
|
| 307 |
"1. Рэлеевское рассеяние — молекулы воздуха рассеивают солнечный свет. "
|
| 308 |
"2. Зависимость от длины волны — короткие волны рассеиваются сильнее. "
|
| 309 |
+
"3. Восприятие глаза — глаз чувствительнее к синему цвету неба.",
|
| 310 |
+
None,
|
| 311 |
+
),
|
| 312 |
+
(
|
| 313 |
+
"Причины такие: 1. Первая причина с длинным пояснением текста. 2. Вторая причина с длинным пояснением текста. 3. Третья причина с длинным пояснением текста.",
|
| 314 |
+
"Причины такие:",
|
| 315 |
+
),
|
| 316 |
+
])
|
| 317 |
+
def test_split_inline_numbered_splits_glued_lists(text, intro):
|
| 318 |
lines = lumen_formatting._split_inline_numbered(text).split("\n")
|
| 319 |
+
if intro is None:
|
| 320 |
+
assert len(lines) == 3
|
| 321 |
+
assert lines[0].startswith("1. ") and lines[1].startswith("2. ") and lines[2].startswith("3. ")
|
| 322 |
+
else:
|
| 323 |
+
assert lines[0] == intro
|
| 324 |
+
assert [line[:2] for line in lines[1:]] == ["1.", "2.", "3."]
|
| 325 |
|
| 326 |
|
| 327 |
def test_split_inline_numbered_ignores_prose_and_versions():
|
|
|
|
| 395 |
assert lumen_formatting._md_to_rich_html("## Title") == "<h3>Title</h3>"
|
| 396 |
|
| 397 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 398 |
def test_md_to_rich_html_splits_three_bullets_prod_movies():
|
| 399 |
# Прод 26.09.2026: ответ про мелодрамы — 3 пункта склеились, rich-путь молчал.
|
| 400 |
text = (
|
|
|
|
| 420 |
# модели OpenRouter) регулярно их всё равно пишут — раньше "###" уходило в
|
| 421 |
# Telegram буквально, без единой защитной сетки (в отличие от таблиц/списков).
|
| 422 |
|
| 423 |
+
@pytest.mark.parametrize(("source", "expected"), [
|
| 424 |
+
("### Как она выводится?", "**Как она выводится?**"),
|
| 425 |
+
("# Заголовок", "**Заголовок**"),
|
| 426 |
+
("## Подзаголовок", "**Подзаголовок**"),
|
| 427 |
+
])
|
| 428 |
+
def test_normalize_headers_converts_atx_to_bold(source, expected):
|
| 429 |
+
assert lumen_formatting._normalize_headers(source) == expected
|
| 430 |
|
| 431 |
|
| 432 |
def test_normalize_headers_does_not_touch_hash_mid_line():
|
|
|
|
| 469 |
|
| 470 |
# ─────────────────── spoiler-тег: защитная сетка (тот же принцип, что <u>) ───────────────────
|
| 471 |
|
| 472 |
+
@pytest.mark.parametrize(("source", "expected"), [
|
| 473 |
+
("<tg-spoiler>секрет</tg-spoiler>", "секрет"),
|
| 474 |
+
('<span class="tg-spoiler">секрет</span>', "секрет"),
|
| 475 |
+
])
|
| 476 |
+
def test_md_to_html_strips_literal_spoiler_tags(source, expected):
|
| 477 |
+
assert lumen_formatting._md_to_html(source) == expected
|
| 478 |
|
| 479 |
|
| 480 |
# ─────────────────── подсветка синтакси��а: язык из ```fence сохраняется ───────────────────
|
| 481 |
+
# (сам кейс с языком покрыт test_md_to_html_code_block выше)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 482 |
|
| 483 |
def test_md_to_html_code_block_without_language_unchanged():
|
| 484 |
assert lumen_formatting._md_to_html("```\nprint(1)\n```") == "<pre>print(1)</pre>"
|
|
|
|
| 486 |
|
| 487 |
# ─────────────────── markdown-цитаты "> " → <blockquote> ───────────────────
|
| 488 |
|
| 489 |
+
@pytest.mark.parametrize(("source", "expected"), [
|
| 490 |
+
("> цитата", "<blockquote>цитата</blockquote>"),
|
| 491 |
+
("> первая строка\n> вторая строка", "<blockquote>первая строка\nвторая строка</blockquote>"),
|
| 492 |
+
])
|
| 493 |
+
def test_md_to_html_converts_blockquotes(source, expected):
|
| 494 |
+
assert lumen_formatting._md_to_html(source) == expected
|
|
|
|
| 495 |
|
| 496 |
|
| 497 |
def test_md_to_html_blockquote_markdown_inside_still_converts():
|
|
|
|
| 563 |
assert lumen_formatting._md_to_rich_html("a | b") == "a | b"
|
| 564 |
|
| 565 |
|
| 566 |
+
@pytest.mark.parametrize(("source", "expected"), [
|
| 567 |
+
("## Заголовок", "<h3>Заголовок</h3>"),
|
| 568 |
+
("### Подзаголовок", "<h4>Подзаголовок</h4>"),
|
| 569 |
+
# Тот же допуск, что у легаси-нормализации: ведущие пробелы и 1–6 решёток.
|
| 570 |
+
(" ## Заголовок", "<h3>Заголовок</h3>"),
|
| 571 |
+
("# Топ", "<h2>Топ</h2>"),
|
| 572 |
+
("#### Глубокий", "<h4>Глубокий</h4>"),
|
| 573 |
+
])
|
| 574 |
+
def test_rich_headings_become_h_tags(source, expected):
|
| 575 |
+
assert lumen_formatting._md_to_rich_html(source) == expected
|
| 576 |
+
|
| 577 |
+
|
| 578 |
+
@pytest.mark.parametrize(("source", "expected"), [
|
| 579 |
+
("корень $x^2$ тут", "корень <tg-math>x^2</tg-math> тут"),
|
| 580 |
+
("формула $$E=mc^2$$ конец", "формула <tg-math-block>E=mc^2</tg-math-block> конец"),
|
| 581 |
+
# Скобочные формы LaTeX (реальный кейс nemotron) извлекаются раньше $.
|
| 582 |
+
("смотри \\[S = \\pi r^{2}\\] конец", "смотри <tg-math-block>S = \\pi r^{2}</tg-math-block> конец"),
|
| 583 |
+
("значение \\(x\\) тут", "значение <tg-math>x</tg-math> тут"),
|
| 584 |
+
])
|
| 585 |
+
def test_rich_math_preserved_as_tg_math_tags(source, expected):
|
| 586 |
# В отличие от _md_to_html (юникод-замена), рич-путь отдаёт LaTeX сырым —
|
| 587 |
# рендерит сервер Telegram.
|
| 588 |
+
assert lumen_formatting._md_to_rich_html(source) == expected
|
|
|
|
| 589 |
|
| 590 |
|
| 591 |
def test_rich_keeps_bold_links_code_quotes_and_escape():
|
|
|
|
| 601 |
assert lumen_formatting._md_to_rich_html("`$x$`") == "<code>$x$</code>"
|
| 602 |
|
| 603 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 604 |
def test_br_tag_becomes_line_break_in_both_paths():
|
| 605 |
# Прод-кейс 17.09.2026: модель пишет "<br>" внутри ячеек таблиц — без
|
| 606 |
# обработки долетает до escape и светится буквально ("...сгор.<br>Pro Max").
|
tests/test_lumen_images.py
CHANGED
|
@@ -13,50 +13,33 @@ lumen_images.py не зависит от Telegram/рантайм-состоян
|
|
| 13 |
import asyncio
|
| 14 |
|
| 15 |
import lumen_images
|
|
|
|
| 16 |
|
| 17 |
|
| 18 |
# ─────────────────────────── _pick_image_model ───────────────────────────
|
| 19 |
|
| 20 |
-
def test_pick_image_model_anime():
|
| 21 |
-
assert lumen_images._pick_image_model("нарисуй девушку в стиле аниме") == "flux-anime"
|
| 22 |
-
assert lumen_images._pick_image_model("draw a chibi character") == "flux-anime"
|
| 23 |
-
assert lumen_images._pick_image_model("manga style portrait") == "flux-anime"
|
| 24 |
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
assert lumen_images._pick_image_model(
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
def test_pick_image_model_empty_prompt_returns_default():
|
| 49 |
-
assert lumen_images._pick_image_model("") == lumen_images.DEFAULT_POLLINATIONS_IMAGE_MODEL
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
def test_pick_image_model_style_keyword_wins_over_quick_keyword():
|
| 53 |
-
# Стилевой сигнал важнее просьбы "побыстрее", если оба есть в одном промпте —
|
| 54 |
-
# аниме проверяется раньше черновика/скетча (см. докстринг _pick_image_model).
|
| 55 |
-
assert lumen_images._pick_image_model("быстро нарисуй аниме-девушку") == "flux-anime"
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
def test_pick_image_model_case_insensitive():
|
| 59 |
-
assert lumen_images._pick_image_model("АНИМЕ ДЕВУШКА") == "flux-anime"
|
| 60 |
|
| 61 |
|
| 62 |
def test_pick_image_model_result_always_a_known_model():
|
|
|
|
| 13 |
import asyncio
|
| 14 |
|
| 15 |
import lumen_images
|
| 16 |
+
import pytest
|
| 17 |
|
| 18 |
|
| 19 |
# ─────────────────────────── _pick_image_model ───────────────────────────
|
| 20 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
+
@pytest.mark.parametrize(("prompt", "expected"), [
|
| 23 |
+
("нарисуй девушку в стиле аниме", "flux-anime"),
|
| 24 |
+
("draw a chibi character", "flux-anime"),
|
| 25 |
+
("manga style portrait", "flux-anime"),
|
| 26 |
+
("нарисуй дракона в фэнтезийном замке", "dreamshaper"),
|
| 27 |
+
("concept art of an elf wizard", "dreamshaper"),
|
| 28 |
+
("рыцарь на фоне волшебного леса", "dreamshaper"),
|
| 29 |
+
("сделай фотореалистичный портрет кота", "flux-realism"),
|
| 30 |
+
("realistic photo of a mountain", "flux-realism"),
|
| 31 |
+
("нарисуй кота как на фото", "flux-realism"),
|
| 32 |
+
("быстрый набросок логотипа", "turbo"),
|
| 33 |
+
("quick sketch of a car", "turbo"),
|
| 34 |
+
("космическая станция на орбите Земли", lumen_images.DEFAULT_POLLINATIONS_IMAGE_MODEL),
|
| 35 |
+
("кот на подоконнике", lumen_images.DEFAULT_POLLINATIONS_IMAGE_MODEL),
|
| 36 |
+
("", lumen_images.DEFAULT_POLLINATIONS_IMAGE_MODEL),
|
| 37 |
+
# Стилевой сигнал важнее просьбы "побыстрее": аниме проверяется раньше черновика.
|
| 38 |
+
("быстро нарисуй аниме-девушку", "flux-anime"),
|
| 39 |
+
("АНИМЕ ДЕВУШКА", "flux-anime"),
|
| 40 |
+
])
|
| 41 |
+
def test_pick_image_model(prompt, expected):
|
| 42 |
+
assert lumen_images._pick_image_model(prompt) == expected
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
|
| 44 |
|
| 45 |
def test_pick_image_model_result_always_a_known_model():
|