SilverElixir commited on
Commit
80c432a
·
2 Parent(s): bfb0cbff3b868f

Merge agent/trim: drop dead code and merge duplicate tests

Browse files
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
- GROQ_BASE_URL = "https://api.groq.com/openai/v1"
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
- def test_match_trigger_prefix_finds_draw_trigger(): assert bot._match_trigger_prefix("нарисуй кота на пляже", bot.DRAW_TRIGGER_PREFIXES) == "нарисуй"
314
-
315
-
316
- def test_match_trigger_prefix_finds_tts_trigger():
317
- assert bot._match_trigger_prefix("озвучь этот текст пожалуйста", bot.TTS_TRIGGER_PREFIXES) == "озвучь"
318
-
319
-
320
- def test_match_trigger_prefix_new_synonyms_work():
321
- assert bot._match_trigger_prefix("преврати в аудио вот это сообщение", bot.TTS_TRIGGER_PREFIXES) == "преврати в аудио"
322
- assert bot._match_trigger_prefix("сгенери картинку заката", bot.DRAW_TRIGGER_PREFIXES) == "сгенери картинку"
323
-
324
-
325
- def test_match_trigger_prefix_no_false_positive_when_trigger_not_at_start():
326
- # Триггерное слово упоминается, но НЕ в начале сообщения — не должно срабатывать
327
- assert bot._match_trigger_prefix("объясни, как я мог бы нарисовать домик карандашом", bot.DRAW_TRIGGER_PREFIXES) is None
328
- assert bot._match_trigger_prefix("что значит слово озвучь на украинском", bot.TTS_TRIGGER_PREFIXES) is None
329
-
330
-
331
- def test_match_trigger_prefix_no_false_positive_for_unrelated_text():
332
- assert bot._match_trigger_prefix("привет, как дела?", bot.DRAW_TRIGGER_PREFIXES) is None
333
- assert bot._match_trigger_prefix("привет, как дела?", bot.TTS_TRIGGER_PREFIXES) is None
334
-
335
-
336
- def test_match_trigger_prefix_ambiguous_phrases_deliberately_excluded():
337
- # "хочу картинку"/"сделай картинку" намеренно НЕ триггеры — легко спутать с
338
- # "хочу картинку тебе показать" или правкой уже присланного фото.
339
- assert bot._match_trigger_prefix("хочу картинку показать тебе", bot.DRAW_TRIGGER_PREFIXES) is None
340
- assert bot._match_trigger_prefix("сделай картинку ярче", bot.DRAW_TRIGGER_PREFIXES) is None
341
-
342
-
343
- def test_match_trigger_prefix_new_voice_and_draw_synonyms():
344
- # Расширение синонимов (сентябрь 2026) — разговорные варианты тех же просьб.
345
- assert bot._match_trigger_prefix("зачитай этот абзац", bot.TTS_TRIGGER_PREFIXES) == "зачитай"
346
- assert bot._match_trigger_prefix("зачитай текст договора", bot.TTS_TRIGGER_PREFIXES) == "зачитай текст"
347
- assert bot._match_trigger_prefix("прочти вслух мою историю", bot.TTS_TRIGGER_PREFIXES) == "прочти вслух"
348
- assert bot._match_trigger_prefix("сгенерируй мне изображение замка", bot.DRAW_TRIGGER_PREFIXES) == "сгенерируй мне изображение"
349
- assert bot._match_trigger_prefix("создай мне картинку с котом", bot.DRAW_TRIGGER_PREFIXES) == "создай мне картинку"
350
-
351
-
352
- def test_match_trigger_prefix_long_phrase_wins_over_short_prefix():
353
- # Порядок в списке: "прочти вслух" стоит раньше "прочти" — иначе короткий
354
- # префикс съест начало ("прочти" вместо "прочти вслух").
355
- assert bot._match_trigger_prefix("озвучь этот текст пожалуйста", bot.TTS_TRIGGER_PREFIXES) == "озвучь"
356
- assert bot._match_trigger_prefix("прочти вслух", bot.TTS_TRIGGER_PREFIXES) == "прочти вслух"
 
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
- def test_pick_image_model_detects_anime():
482
- assert bot._pick_image_model("нарисуй девушку в стиле аниме") == "flux-anime"
483
- assert bot._pick_image_model("draw a chibi character") == "flux-anime"
484
-
485
-
486
- def test_pick_image_model_detects_fantasy():
487
- assert bot._pick_image_model("нарисуй дракона в фэнтезийном замке") == "dreamshaper"
488
- assert bot._pick_image_model("concept art of an elf wizard") == "dreamshaper"
489
-
490
-
491
- def test_pick_image_model_detects_realism():
492
- assert bot._pick_image_model("сделай фотореалистичный портрет кота") == "flux-realism"
493
- assert bot._pick_image_model("realistic photo of a mountain") == "flux-realism"
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
- def test_match_pick_request_detects_taste_requests():
790
- assert bot.match_pick_request("посоветуй фильм") == "film"
791
- assert bot.match_pick_request("порекомендуй интересную книгу") == "books"
792
- assert bot.match_pick_request("подскажи музыку для тренировки") == "music"
793
- assert bot.match_pick_request("накидай сериалов") == "series"
794
- assert bot.match_pick_request("придумай игру для компании") == "games"
795
  # Английские запросы — тот же детектор (дефолтный язык бота — en).
796
- assert bot.match_pick_request("recommend a movie") == "film"
797
- assert bot.match_pick_request("suggest music for training") == "music"
798
-
799
-
800
- def test_match_pick_request_rejects_detailed_or_unrelated():
801
  # Длинный запрос с деталями — обычным путём в модель, без кнопок.
802
- assert bot.match_pick_request("посоветуй фильм про космос, ужасы, 2024 год, длинный список") is None
803
- assert bot.match_pick_request("привет, как дела?") is None
804
- assert bot.match_pick_request("нарисуй кота") is None
805
- assert bot.match_pick_request("") is None
806
  # Латиница — только по границам слов: "notebook" — не книги.
807
- assert bot.match_pick_request("recommend a notebook") is None
808
- # Кириллица — по началу слова: "тигр" — не игры (AUD-E-006), "игру" — игры.
809
- assert bot.match_pick_request("посоветуй тигра") is None
810
- assert bot.match_pick_request("придумай игру для компании") == "games"
811
-
812
-
813
- def test_match_pick_request_rejects_verb_forms_of_igrat():
814
- # Аудит A6-6: префикс "игр" ловил глаголы — "кто играет" не запрос игры.
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
- def test_classify_model_error_rate_limit_by_status():
24
- assert bot._classify_model_error(429, "") == "rate_limit"
25
-
26
-
27
- def test_classify_model_error_rate_limit_by_text():
28
- assert bot._classify_model_error(None, "quota exceeded") == "rate_limit"
29
-
30
-
31
- def test_classify_model_error_paid():
32
- assert bot._classify_model_error(402, "") == "paid"
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
- def test_next_fallback_model_none_tried_returns_first():
56
- assert bot._next_fallback_model(set(), ["a", "b", "c"]) == "a"
 
 
 
 
 
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
- def test_ask_openrouter_text_empty_model_chain_fallback_is_not_dead_model():
 
215
  # РЕГРЕССИЯ (24.07.2026): раньше запасным вариантом на случай пустого model_chain
216
- # в ask_openrouter_text было "meta-llama/llama-3.3-70b-instruct:free" — та же
217
- # модель, что подтверждённо снята провайдером с бесплатного тира (см. README,
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
- asyncio.run(bot.ask_openrouter_text(chat_id, "привет", model_chain=[]))
233
- assert calls[0] == [bot._OR_LIGHT_ORDER[0]]
 
 
 
 
 
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
- def test_extract_gemini_answer_falls_through_when_retry_raises():
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 = retry_raises
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
- def test_or_chat_completion_with_fallback_switches_model_on_rate_limit():
1008
- async def fake_or_request(path, method="GET", *, json_body=None, deadline=None):
1009
- model = json_body["model"]
1010
- if model == "model-a":
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("forbidden", status_code=403)
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
- def test_or_request_scrubs_api_key_from_network_exception_message():
1167
- class _FakeSessionRaisingWithKeyInMessage:
 
 
 
 
 
 
 
1168
  def request(self, *args, **kwargs):
1169
- raise RuntimeError("connection failed, headers were: Authorization: Bearer fake-secret-or-key-123")
1170
 
1171
  async def fake_get_http_session():
1172
- return _FakeSessionRaisingWithKeyInMessage()
1173
 
1174
- original_get_session = bot._get_http_session
1175
- original_key = bot.OPENROUTER_API_KEY
1176
- bot._get_http_session = fake_get_http_session
1177
- bot.OPENROUTER_API_KEY = "fake-secret-or-key-123"
1178
- try:
1179
- with pytest.raises(bot.OpenRouterAPIError) as exc_info:
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
- def test_ask_openrouter_text_trims_history_with_summary_not_silent_cut(monkeypatch):
 
 
 
 
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 "ответ", trial_models[0]
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": "ответ groq"}}]}
 
 
1636
 
1637
  async def fake_summarize(text):
1638
  return "итог: погода и коты"
1639
 
1640
- monkeypatch.setattr(bot, "_groq_request", fake_groq_request)
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.ask_groq_text(chat_id, "новый вопрос", model_chain=["m1"]))
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": "ответ groq"},
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
- def test_gemini_success_logs_token_usage_when_api_returns_it(caplog):
 
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
- resp.usage_metadata = SimpleNamespace(prompt_token_count=10, candidates_token_count=20, total_token_count=30)
 
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
- assert len(usage_lines) == 1
1931
- line = usage_lines[0]
1932
- assert f"chat={chat_id}" in line and "provider=gemini" in line
1933
- assert "prompt=10" in line and "completion=20" in line and "total=30" in line
1934
- finally:
1935
- bot.client = original_client
1936
- bot.chat_state.pop(chat_id, None)
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
- def test_split_text_chunks_lives_in_formatting_module():
54
- # Срез монолита (сентябрь 2026): реализация — в lumen_formatting, bot.py
55
- # только ре-экспортирует имя, чтобы bot._split_text_chunks работал как раньше.
56
- import lumen_formatting
57
- assert bot._split_text_chunks is lumen_formatting._split_text_chunks
58
-
59
-
60
- def test_sanitize_mime_type_guesses_from_extension():
61
- assert bot._sanitize_mime_type("photo.jpg", "") == "image/jpeg"
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
- def test_is_tiktok_true_for_tiktok_url():
108
- assert bot.is_tiktok("https://www.tiktok.com/@user/video/123") is True
109
-
110
-
111
- def test_is_tiktok_false_for_other_url():
112
- assert bot.is_tiktok("https://youtube.com/watch?v=1") is False
113
-
114
-
115
- def test_is_youtube_true_for_short_link():
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
- def test_extract_url_returns_none_when_no_url():
128
- assert bot.extract_url("no url here") is None
 
 
 
 
129
 
130
 
131
- def test_clean_mention_removes_username():
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
- def test_setup_logging_respects_log_level_env(monkeypatch):
673
- # РЕГРЕССИЯ: раньше уровень был захардкожен INFO везде (root + оба handler'а) —
674
- # log.debug(...) не печатался ни при каком окружении. LOG_LEVEL теперь читается
675
- # так же, как и любой другой тюнинг в проекте.
 
 
676
  import logging as _logging
677
- monkeypatch.setenv("LOG_LEVEL", "DEBUG")
 
 
 
678
  try:
679
  bot._setup_logging()
680
- assert _logging.getLogger().level == _logging.DEBUG
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
- def test_rate_limit_key_for_message_uses_from_user_when_present():
1899
- msg = SimpleNamespace(
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=None, sender_chat=SimpleNamespace(id=-100777),
 
1910
  chat=SimpleNamespace(id=-100999),
1911
  )
1912
- assert bot._rate_limit_key_for_message(msg) == -100777
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
- def test_should_only_record_passively_true_for_unmentioned_group_text():
1934
- msg = _FakeIncomingMessage(1)
1935
- assert bot._should_only_record_passively(msg, "привет всем", is_private=False, is_guest=False, mentioned=False) is True
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
- text = "гляньте https://www.tiktok.com/@user/video/123"
1949
- assert bot._should_only_record_passively(msg, text, is_private=False, is_guest=False, mentioned=False) is False
 
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
- def test_original_sound_label_russian():
107
- assert bot._original_sound_label("ru") == "Оригинальный звук"
108
-
109
-
110
- def test_original_sound_label_ukrainian():
111
- assert bot._original_sound_label("uk") == "Оригінальний звук"
112
-
113
-
114
- def test_original_sound_label_belarusian():
115
- assert bot._original_sound_label("be") == "Арыгінальны гук"
116
-
117
-
118
- def test_original_sound_label_english():
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
- assert bot._original_sound_label("de", "uk") == "Originalton"
146
- assert bot._original_sound_label(None, "uk") == "Оригінальний звук"
147
- assert bot._original_sound_label("sw", "kk") == "Түпнұсқа дыбыс"
148
- assert bot._original_sound_label("th", "kk") == "เสียงต้นฉบับ"
149
- assert bot._original_sound_label("sw", "xx") == "Original sound"
150
- assert bot._original_sound_label(None, None) == "Original sound"
151
-
152
-
153
- def test_tiktok_music_page_id_extracts_trailing_numeric_id():
154
- url = "https://www.tiktok.com/music/original-sound-7666630127215823637"
155
- assert bot._tiktok_music_page_id(url) == "7666630127215823637"
156
-
157
-
158
- def test_tiktok_music_page_id_extracts_id_with_cyrillic_slug():
159
- # Реальный найденный в логах случай — слаг на русском языке.
160
- url = "https://www.tiktok.com/music/оригинальный-звук-7667114246303812385"
161
- assert bot._tiktok_music_page_id(url) == "7667114246303812385"
162
-
163
-
164
- def test_tiktok_music_page_id_handles_trailing_slash():
165
- url = "https://www.tiktok.com/music/original-sound-7666630127215823637/"
166
- assert bot._tiktok_music_page_id(url) == "7666630127215823637"
167
-
168
-
169
- def test_tiktok_music_page_id_none_for_regular_video_link():
170
- url = "https://www.tiktok.com/@someuser/video/7370000000000000001"
171
- assert bot._tiktok_music_page_id(url) is None
172
-
173
-
174
- def test_tiktok_music_page_id_none_for_photo_post_link():
175
- url = "https://www.tiktok.com/@someuser/photo/7370000000000000002"
176
- assert bot._tiktok_music_page_id(url) is None
177
-
178
-
179
- def test_tiktok_music_page_id_none_for_named_track_slug():
180
- # Именованные треки/песни тоже используют /music/, просто со своим слагом —
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
- def test_slideshow_slide_urls_prefers_live_images_when_present():
247
- media_data = {"live_images": ["https://tikwm.com/live0.mp4", ""]}
248
- images_to_fetch = ["https://tikwm.com/photo0.jpg", "https://tikwm.com/photo1.jpg"]
249
- assert bot._slideshow_slide_urls(media_data, images_to_fetch) == [
250
- "https://tikwm.com/live0.mp4", "https://tikwm.com/photo1.jpg",
251
- ]
252
-
253
-
254
- def test_slideshow_slide_urls_falls_back_when_live_images_absent():
255
- images_to_fetch = ["https://tikwm.com/photo0.jpg", "https://tikwm.com/photo1.jpg"]
256
- assert bot._slideshow_slide_urls({}, images_to_fetch) == images_to_fetch
257
-
258
-
259
- def test_slideshow_slide_urls_falls_back_when_live_images_shorter():
260
- media_data = {"live_images": ["https://tikwm.com/live0.mp4"]}
261
- images_to_fetch = ["https://tikwm.com/photo0.jpg", "https://tikwm.com/photo1.jpg"]
262
- assert bot._slideshow_slide_urls(media_data, images_to_fetch) == [
263
- "https://tikwm.com/live0.mp4", "https://tikwm.com/photo1.jpg",
264
- ]
265
-
266
-
267
- def test_slideshow_slide_urls_ignores_non_list_live_images():
268
- media_data = {"live_images": "not-a-list"}
269
- images_to_fetch = ["https://tikwm.com/photo0.jpg"]
270
- assert bot._slideshow_slide_urls(media_data, images_to_fetch) == images_to_fetch
271
-
272
-
273
- def test_looks_like_video_bytes_true_for_mp4_ftyp_signature():
274
  # Реальная сигнатура начала MP4/MOV-контейнера: 4 байта размера бокса + "ftyp".
275
- mp4_header = b"\x00\x00\x00\x18ftypmp42\x00\x00\x00\x00mp42isom"
276
- assert bot._looks_like_video_bytes(mp4_header) is True
277
-
278
-
279
- def test_looks_like_video_bytes_false_for_jpeg():
280
- jpeg_header = b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00"
281
- assert bot._looks_like_video_bytes(jpeg_header) is False
282
-
283
-
284
- def test_looks_like_video_bytes_false_for_png():
285
- png_header = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR"
286
- assert bot._looks_like_video_bytes(png_header) is False
287
-
288
-
289
- def test_looks_like_video_bytes_false_for_too_short_input():
290
- assert bot._looks_like_video_bytes(b"\x00\x00\x00\x18fty") is False
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
- items = list(range(35))
 
 
 
 
333
  chunks = bot._chunk_tiktok_media_items(items)
334
- assert [len(c) for c in chunks] == [10, 10, 10, 5]
335
-
336
-
337
- def test_chunk_tiktok_media_items_empty_list():
338
- assert bot._chunk_tiktok_media_items([]) == []
 
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
- def test_circuit_breaker_does_not_trip_before_threshold():
 
30
  breaker = bot._TelegramProxyCircuitBreaker(cooldown_sec=20.0, trip_threshold=3)
31
- assert breaker.note_failure() is False
32
- assert breaker.note_failure() is False
33
- assert breaker.is_down(time.monotonic()) is False
34
-
35
-
36
- def test_circuit_breaker_trips_at_threshold():
37
- breaker = bot._TelegramProxyCircuitBreaker(cooldown_sec=20.0, trip_threshold=3)
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
- def test_rotate_telegram_proxy_switches_and_reports_lap_not_done():
 
 
 
 
 
 
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 = 0
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 True # ещё не замкнули круг — есть смысл пробовать сразу
167
- assert bot.TELEGRAM_API_BASE_URL == "https://fallback.example.com"
168
- assert bot._telegram_proxy_idx == 1
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 lumen_streaming
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
- def test_md_to_html_empty_string():
41
- assert lumen_formatting._md_to_html("") == ""
42
-
43
-
44
- def test_md_to_html_bold():
45
- assert lumen_formatting._md_to_html("**bold**") == "<b>bold</b>"
46
-
47
-
48
- def test_md_to_html_italic():
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
- # Язык фенса теперь сохраняется как class="language-x" (подсветка синтаксиса
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 &lt; 10"
113
 
114
 
115
- def test_md_to_html_converts_markdown_table_to_bullet_list():
116
- # Регрессия на реальный найденный при тестировании баг: Telegram не рендерит
117
- # markdown-таблицы НИ В КАКОМ режиме — пользователь видел сырой текст с "|" и
118
- # "---" вместо аккуратной таблицы (подтверждено скриншотами реального теста).
119
- text = (
120
  "| Аспект | React | Vue |\n"
121
  "|--------|-------|-----|\n"
122
  "| Кривая обучения | Высокая | Низкая |\n"
123
- "| Сообщество | Огромное | Среднее |"
124
- )
125
- result = lumen_formatting._md_to_html(text)
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
- text = (
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 "<b>Название:</b> Кофе" in result
144
- assert "<b>Цена:</b> 150" in result
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
- def test_scrub_latex_converts_bracket_delimiters_and_pi_and_superscript():
170
- result = lumen_formatting._scrub_latex(r"Площадь: \[ S = \pi r^{2} \]")
171
- assert "\\[" not in result and "\\]" not in result
172
- assert "π" in result
173
- assert "r²" in result
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
- def test_scrub_latex_converts_frac_and_sqrt():
183
- assert lumen_formatting._scrub_latex(r"\frac{1}{2}") == "1/2"
184
- assert lumen_formatting._scrub_latex(r"\sqrt{16}") == "√16"
 
 
 
 
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
- def test_normalize_bullet_markers_converts_dash_and_asterisk():
248
- assert lumen_formatting._normalize_bullet_markers("- Пункт один\n- Пункт два") == "• Пункт один\n• Пункт два"
249
- assert lumen_formatting._normalize_bullet_markers("* Пункт один\n* Пункт два") == "• Пункт один\n• Пункт два"
250
-
251
-
252
- def test_normalize_bullet_markers_preserves_indentation():
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
- def test_split_inline_bullets_splits_three_item_prod_answer():
287
- # Прод 26.09.2026: ровно 3 пункта (2 разделителя) — самый частый живой случай,
288
- # со старым порогом 3 не ловился вообще (ответы про небо и мелодрамы).
289
- text = (
290
  "• Рассеяние Рэлея: мелкие частицы в атмосфере рассеивают коротковолновый свет. "
291
  "• Солнечный спектр: солнце излучает больше синего света днём и ночью. "
292
- "• Отсутствие поглощения: газы атмосферы почти не поглощают синий свет."
293
- )
294
- assert len(text) >= 200
295
- lines = lumen_formatting._split_inline_bullets(text).split("\n")
296
- assert len(lines) == 3
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
- assert len(text) >= 200
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
- text = (
317
  "Итоги такие: первый пункт с подробностями для длины строки и смыслом · второй пункт с подробностями "
318
- "для длины строки и смыслом · третий пункт с подробностями для длины строки и смыслом · четвёртый пункт."
319
- )
 
 
 
320
  assert len(text) >= 200
321
  lines = lumen_formatting._split_inline_bullets(text).split("\n")
322
- assert len(lines) == 4
323
- assert lines[0].startswith("Итоги такие: ")
324
- assert all(line.startswith("• ") for line in lines[1:])
 
 
 
 
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
- def test_split_inline_numbered_splits_glued_sky_answer():
335
  # Прод 25.09.2026: модель написала "1. ... 2. ... 3. ..." одним абзацем.
336
- text = (
337
  "1. Рэлеевское рассеяние — молекулы воздуха рассеивают солнечный свет. "
338
  "2. Зависимость от длины волны — короткие волны рассеиваются сильнее. "
339
- "3. Восприятие глаза — глаз чувствительнее к синему цвету неба."
340
- )
341
- lines = lumen_formatting._split_inline_numbered(text).split("\n")
342
- assert len(lines) == 3
343
- assert lines[0].startswith("1. ") and lines[1].startswith("2. ") and lines[2].startswith("3. ")
344
-
345
-
346
- def test_split_inline_numbered_keeps_intro_on_own_line():
347
- text = "Причины такие: 1. Первая причина с длинным пояснением текста. 2. Вторая причина с длинным пояснением текста. 3. Третья причина с длинным пояснением текста."
348
  lines = lumen_formatting._split_inline_numbered(text).split("\n")
349
- assert lines[0] == "Причины такие:"
350
- assert [l[:2] for l in lines[1:]] == ["1.", "2.", "3."]
 
 
 
 
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
- def test_normalize_headers_converts_h3_to_bold():
459
- assert lumen_formatting._normalize_headers("### Как она выводится?") == "**Как она выводится?**"
460
-
461
-
462
- def test_normalize_headers_converts_h1_and_h2():
463
- assert lumen_formatting._normalize_headers("# Заголовок") == "**Заголовок**"
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
- def test_md_to_html_strips_literal_spoiler_tag():
508
- assert lumen_formatting._md_to_html("<tg-spoiler>секрет</tg-spoiler>") == "секрет"
509
-
510
-
511
- def test_md_to_html_strips_literal_span_spoiler_tag():
512
- assert lumen_formatting._md_to_html('<span class="tg-spoiler">секрет</span>') == "секрет"
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
- def test_md_to_html_converts_single_line_blockquote():
529
- assert lumen_formatting._md_to_html("> цитата") == "<blockquote>цитата</blockquote>"
530
-
531
-
532
- def test_md_to_html_converts_multiline_blockquote_as_one_block():
533
- result = lumen_formatting._md_to_html("> первая строка\n> вторая строка")
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
- def test_rich_headings_become_h_tags():
607
- assert lumen_formatting._md_to_rich_html("## Заголовок") == "<h3>Заголовок</h3>"
608
- assert lumen_formatting._md_to_rich_html("### Подзаголовок") == "<h4>Подзаголовок</h4>"
609
-
610
-
611
- def test_rich_math_preserved_as_tg_math_tags():
 
 
 
 
 
 
 
 
 
 
 
 
 
 
612
  # В отличие от _md_to_html (юникод-замена), рич-путь отдаёт LaTeX сырым —
613
  # рендерит сервер Telegram.
614
- assert lumen_formatting._md_to_rich_html("корень $x^2$ тут") == "корень <tg-math>x^2</tg-math> тут"
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 &lt; 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
- def test_pick_image_model_fantasy():
27
- assert lumen_images._pick_image_model("нарисуй дракона в фэнтезийном замке") == "dreamshaper"
28
- assert lumen_images._pick_image_model("concept art of an elf wizard") == "dreamshaper"
29
- assert lumen_images._pick_image_model("рыцарь на фоне волшебного леса") == "dreamshaper"
30
-
31
-
32
- def test_pick_image_model_realism():
33
- assert lumen_images._pick_image_model("сделай фотореалистичный портрет кота") == "flux-realism"
34
- assert lumen_images._pick_image_model("realistic photo of a mountain") == "flux-realism"
35
- assert lumen_images._pick_image_model("нарисуй кота как на фото") == "flux-realism"
36
-
37
-
38
- def test_pick_image_model_quick_draft():
39
- assert lumen_images._pick_image_model("быстрый набросок логотипа") == "turbo"
40
- assert lumen_images._pick_image_model("quick sketch of a car") == "turbo"
41
-
42
-
43
- def test_pick_image_model_falls_back_to_default_for_generic_prompt():
44
- assert lumen_images._pick_image_model("космическая станция на орбите Земли") == lumen_images.DEFAULT_POLLINATIONS_IMAGE_MODEL
45
- assert lumen_images._pick_image_model("кот на подоконнике") == lumen_images.DEFAULT_POLLINATIONS_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():