Spaces:
Running
Running
File size: 51,604 Bytes
62c1832 9a08963 87f3899 e89951a 9a08963 f40e9a4 9a08963 87f3899 9a08963 87f3899 9a08963 e89951a 1190087 2939738 1dfad36 1190087 9a08963 87f3899 9a08963 87f3899 9a08963 87f3899 9a08963 87f3899 9a08963 87f3899 9a08963 87f3899 9a08963 87f3899 9a08963 87f3899 9a08963 87f3899 9a08963 693519a 76aeacc 87f3899 76aeacc 87f3899 281f95c 87f3899 281f95c 87f3899 281f95c 87f3899 d926362 87f3899 d926362 281f95c 87f3899 281f95c 693519a 87f3899 d59ebea 87f3899 d59ebea 87f3899 d59ebea 87f3899 d59ebea aa3af88 d59ebea aa3af88 d59ebea 5a68db4 aa3af88 5a68db4 aa3af88 76aeacc aa3af88 76aeacc 9a08963 cc503f9 87f3899 cc503f9 9a08963 87f3899 9a08963 fd5cff9 9a08963 87f3899 9a08963 e6073ca 9a08963 339b914 87f3899 339b914 87f3899 339b914 9d7cca0 339b914 62c1832 3d3384a 62c1832 3d3384a 62c1832 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 | """
test_lumen_formatting.py — юнит-тесты на lumen_formatting.py: конвертация markdown-подобного
текста Lumen в Telegram HTML (_md_to_html), защитная сетка от сырого LaTeX (_scrub_latex),
нормализация маркеров списков (_normalize_bullet_markers).
Часть разбиения test_bot_helpers.py по модулям вслед за уже существующим разбиением
исходников (lumen_formatting.py / lumen_security.py / lumen_router_config.py / bot.py) —
см. README, аудит техдолга. lumen_formatting.py не имеет ни одной зависимости от
Telegram/Gemini/OpenRouter/рантайм-состояния бота, поэтому этот файл тестирует его
напрямую (import lumen_formatting), без импорта bot.py — не привязан к переменным
окружения BOT_TOKEN/GEMINI_API_KEY и т.п., которые conftest.py подставляет только ради
самого bot.py.
Запуск:
pytest test_lumen_formatting.py -v
"""
import lumen_formatting
import pytest
import time
# ─────────────────────────── _truncate_html_to_fit ───────────────────────────
def test_truncate_html_to_fit_keeps_short_text_and_cuts_at_source_boundary():
# Регрессия AUD-J-002: рез готового HTML рвал теги ("can't parse entities").
# Переехала из test_bot.py вслед за функцией (TG_MAX_LEN=4096 — лимит Telegram).
short = lumen_formatting._truncate_html_to_fit("привет", 4096)
assert short == lumen_formatting._md_to_html("привет")
long_md = "**" + "x" * 5000 + "**"
cut = lumen_formatting._truncate_html_to_fit(long_md, 4096)
assert len(cut) <= 4096
assert cut.endswith("…")
assert cut.count("<b>") == cut.count("</b>")
# ─────────────────────────── _md_to_html ───────────────────────────
@pytest.mark.parametrize(("source", "expected"), [
("", ""),
("**bold**", "<b>bold</b>"),
("*italic*", "<i>italic</i>"),
("~~strike~~", "<s>strike</s>"),
("`code`", "<code>code</code>"),
])
def test_md_to_html_basic_markup(source, expected):
assert lumen_formatting._md_to_html(source) == expected
def test_md_to_html_escapes_raw_html():
# Сырой HTML от модели не должен пролезть как есть — иначе Telegram
# либо сломает parse_mode=HTML, либо (хуже) отрендерит чужую разметку.
assert lumen_formatting._md_to_html("<script>alert(1)</script>") == "<script>alert(1)</script>"
def test_md_to_html_inline_code():
assert lumen_formatting._md_to_html("`code`") == "<code>code</code>"
def test_md_to_html_code_block():
# Язык фенса сохраняется как class="language-x" для подсветки в Telegram.
assert lumen_formatting._md_to_html("```python\nprint(1)\n```") == '<pre><code class="language-python">print(1)</code></pre>'
def test_md_to_html_escapes_inside_code_block():
# Код-теги внутри code/pre тоже обязаны экранироваться, иначе строка вида
# "`<tag>`" сломает HTML-разметку сообщения в Telegram.
assert lumen_formatting._md_to_html("`<tag>`") == "<code><tag></code>"
def test_md_to_html_no_html_injection_via_markdown_markers():
# Регрессионный тест на реальный инцидент: markdown-символы внутри текста
# не должны давать невалидную HTML-разметку (непарные теги).
result = lumen_formatting._md_to_html("**bold** and *italic* and `code`")
assert result.count("<b>") == result.count("</b>")
assert result.count("<i>") == result.count("</i>")
assert result.count("<code>") == result.count("</code>")
def test_table_separator_match_is_time_bounded():
# Квадратичный регекс на пробельных хвостах зависал: строка режется до match.
line = "| a | b |\n" + " " * 12000 + "-"
started = time.monotonic()
lumen_formatting._convert_markdown_tables_to_lists(line)
assert time.monotonic() - started < 1.0
# Обычный разделитель по-прежнему детектится.
assert lumen_formatting._is_table_separator("|---|---|") is True
assert lumen_formatting._is_table_separator(" --- | --- ") is True
assert lumen_formatting._is_table_separator("просто текст") is False
def test_split_chunks_fit_html_and_utf16_units():
# Лимит считается по итоговому HTML в units: раздутая разметка и эмодзи
# вне BMP больше не дают 400 от Telegram.
bold_ru = "**" + "привет " * 600 + "**"
chunks = lumen_formatting._split_text_chunks(bold_ru, 4096)
assert len(chunks) > 1
for chunk in chunks:
assert lumen_formatting._tg_len(lumen_formatting._md_to_html(chunk)) <= 4096
emoji = "😀" * 3000
emoji_chunks = lumen_formatting._split_text_chunks(emoji, 4096)
assert len(emoji_chunks) == 2
for chunk in emoji_chunks:
assert lumen_formatting._tg_len(chunk) <= 4096
assert "".join(emoji_chunks) == emoji
# Граница: ровно 4096 units одним чанком, +1 эмодзи уже двумя.
assert len(lumen_formatting._split_text_chunks("😀" * 2048, 4096)) == 1
assert len(lumen_formatting._split_text_chunks("😀" * 2049, 4096)) == 2
assert len(lumen_formatting._split_text_chunks("я" * 4096, 4096)) == 1
def test_split_chunks_keep_fences_intact():
# Длинный забор делится построчно с переоткрытием: каждый кусок рендерится.
code = "```python\n" + "x = 1\n" * 1500 + "```"
chunks = lumen_formatting._split_text_chunks(code, 4096)
assert len(chunks) > 1
for chunk in chunks:
assert chunk.startswith("```") and chunk.rstrip().endswith("```")
assert "<pre>" in lumen_formatting._md_to_html(chunk)
assert "```" not in lumen_formatting._md_to_html(chunk)
def test_split_chunks_merge_short_fenced_message_back():
# Короткое сообщение с кодом едет одним куском, а не тремя.
text = "Hi\n```python\nx = 1\n```\nBye"
chunks = lumen_formatting._split_text_chunks(text, 4096)
assert chunks == [text]
def test_split_chunks_presplit_inline_bullets_before_cutting():
# Прод 07.10.2026: чанкер резал слипшийся список по ". " до сплиттера,
# куски с 1 маркером не делились и в чат уходила каша.
text = (
"Лучше проприетарные системы, когда: • Требуется гарантированная поддержка "
"с длинным пояснением для набора длины строки. • Важна интеграция из коробки "
"с длинным пояснением для набора длины строки. • Нужна безопасность за счет "
"сокрытия с длинным пояснением для набора длины строки."
)
chunks = lumen_formatting._split_text_chunks(text, 4096)
assert any("\n• " in c for c in chunks)
def test_md_to_html_link_with_inline_code_has_no_placeholder_leak():
# Код внутри метки ссылки: плейсхолдер не протекает, оба тега на месте.
html = lumen_formatting._md_to_html("[`code` текст](https://example.com)")
assert "\x00" not in html
assert '<a href="https://example.com">' in html
assert "<code>code</code>" in html
def test_strip_markdown_keeps_underscores_in_urls_and_filenames():
# Подчёркивания внутри слов не выделение: URL, файлы, эмодзи рядом.
assert lumen_formatting._strip_markdown("см. https://example.com/my_file_name тут") == "см. https://example.com/my_file_name тут"
assert lumen_formatting._strip_markdown("открой my_file_name.txt и отчёт_📄_финал.txt") == "открой my_file_name.txt и отчёт_📄_финал.txt"
assert lumen_formatting._strip_markdown("файл_имя with-hyphen остаётся") == "файл_имя with-hyphen остаётся"
# Настоящие выделения по-прежнему чистятся, включая кириллицу и эмодзи.
assert lumen_formatting._strip_markdown("**жирно** и *курсив*") == "жирно и курсив"
assert lumen_formatting._strip_markdown("привет _друг_ 😀") == "привет друг 😀"
assert lumen_formatting._strip_markdown("*😀*") == "😀"
def test_md_to_html_normalizes_raw_html_bold_tag():
# Регрессия: модель иногда пишет литеральные <b>/<i> теги вместо markdown
# (несмотря на инструкцию в system_prompt.py) — раньше это экранировалось
# и показывалось пользователю как видимый мусорный текст вида "<b>".
assert lumen_formatting._md_to_html("<b>Заголовок</b>") == "<b>Заголовок</b>"
def test_md_to_html_strips_broken_self_closing_tag():
# Реальный найденный баг: модель иногда пишет невалидный self-closing "<b/>".
result = lumen_formatting._md_to_html("текст <b/> ещё текст")
assert "<b/>" not in result
assert "<b/>" not in result
def test_md_to_html_normalizes_raw_html_code_and_pre():
assert lumen_formatting._md_to_html("код: <code>print(1)</code>") == "код: <code>print(1)</code>"
result = lumen_formatting._md_to_html("<pre>def f():\n pass</pre>")
assert result.startswith("<pre>") and result.endswith("</pre>")
def test_md_to_html_still_escapes_ordinary_comparison_operators():
# Убеждаемся, что нормализация тегов не сломала обычное экранирование —
# "5 < 10" не должно превращаться в незакрытый тег.
assert lumen_formatting._md_to_html("сравнение: 5 < 10") == "сравнение: 5 < 10"
@pytest.mark.parametrize(("text", "present"), [
# Регрессия: Telegram не рендерит markdown-таблицы ни в каком режиме —
# пользователь видел сырой текст с "|" и "---" (подтверждено скриншотами).
(
"| Аспект | React | Vue |\n"
"|--------|-------|-----|\n"
"| Кривая обучения | Высокая | Низкая |\n"
"| Сообщество | Огромное | Среднее |",
("<b>Аспект:</b> Кривая обучения", "<b>React:</b> Высокая", "<b>Vue:</b> Низкая"),
),
# Некоторые модели пишут таблицы без внешних "|" по краям строки.
(
"Название | Цена\n"
"---|---\n"
"Кофе | 150\n"
"Чай | 100",
("<b>Название:</b> Кофе", "<b>Цена:</b> 150"),
),
])
def test_md_to_html_converts_markdown_tables_to_bullet_lists(text, present):
result = lumen_formatting._md_to_html(text)
assert "|" not in result
assert all(p in result for p in present)
assert result.count("•") == 2
def test_md_to_html_does_not_touch_pipes_inside_code_block():
# "|" внутри блока кода (например, побитовое ИЛИ в Rust/C) не должно
# ошибочно распознаваться как таблица — код уже вынесен на предыдущем шаге.
text = "```rust\nlet x = a | b;\nlet y = c | d;\n```"
result = lumen_formatting._md_to_html(text)
assert "let x = a | b;" in result
assert "•" not in result
def test_md_to_html_no_false_positive_on_plain_text_with_dashes():
# Обычный текст с дефисами (не таблица) не должен ломаться конвертером.
text = "Список дел:\n- сходить в магазин\n- купить хлеб"
result = lumen_formatting._md_to_html(text)
assert "сходить в магазин" in result
assert "купить хлеб" in result
# ─────────────────── LaTeX-скрубер (защитная сетка от сырого LaTeX) ───────────────────
# Регрессия на реальный найденный при калибровке случай: nemotron-3-nano-30b-a3b:free
# выдала "\[ S = \pi r^{2}, \]" и "\(x^{2}+y^{2}=r^{2}\)" вместо юникода, несмотря на
# явный запрет LaTeX в system_prompt.py.
@pytest.mark.parametrize(("source", "gone", "present"), [
(r"Площадь: \[ S = \pi r^{2} \]", ("\\[", "\\]"), ("π", "r²")),
(r"формула \(x^{2}+y^{2}=r^{2}\)", ("\\(", "\\)"), ("x²+y²=r²",)),
])
def test_scrub_latex_converts_delimited_math(source, gone, present):
result = lumen_formatting._scrub_latex(source)
assert all(g not in result for g in gone)
assert all(p in result for p in present)
@pytest.mark.parametrize(("source", "expected"), [
(r"\frac{1}{2}", "1/2"),
(r"\sqrt{16}", "√16"),
("обычный текст без формул", "обычный текст без формул"),
("$$x^2 + y^2$$", "x² + y²"),
])
def test_scrub_latex_exact_replacements(source, expected):
assert lumen_formatting._scrub_latex(source) == expected
def test_scrub_latex_converts_common_symbols():
result = lumen_formatting._scrub_latex(r"\times \pm \leq \geq \infty \sum \int")
for leftover in ("\\times", "\\pm", "\\leq", "\\geq", "\\infty", "\\sum", "\\int"):
assert leftover not in result
assert "×" in result and "±" in result and "≤" in result and "≥" in result and "∞" in result
def test_scrub_latex_does_not_confuse_currency_with_math_delimiters():
# РЕГРЕССИЯ, найденная при code-review (25 июля 2026): первая версия скрубера
# обрабатывала и одиночный "$...$" как инлайн-LaTeX. Если в одном сообщении
# встречались и сумма в долларах, и настоящая формула ("цена $100, а формула
# $x^2$ рядом"), первый "$" суммы ошибочно спаривался с первым "$" формулы —
# результат был ХУЖЕ исходного: обрезанные суммы плюс осиротевший "$" в хвосте
# ("цена 100, а формула x²$ рядом"). Одиночный "$" теперь не обрабатывается
# вообще — только "$$...$$". Сами доллары остаются нетронутыми в обоих случаях;
# "x^2" внутри всё равно аккуратно превращается в "x²" — это отдельная, не
# завязанная на "$"-разделители замена (см. test_scrub_latex_exact_replacements),
# она безвредна и здесь, и вне контекста "$".
assert lumen_formatting._scrub_latex("цена $100, а формула $x^2$ рядом") == "цена $100, а формула $x²$ рядом"
assert lumen_formatting._scrub_latex("первый вариант — $50, второй — $100") == "первый вариант — $50, второй — $100"
assert lumen_formatting._scrub_latex("стоимость: $100. Итого: $200.") == "стоимость: $100. Итого: $200."
def test_scrub_latex_order_sensitive_replacements_dont_corrupt_each_other():
# РЕГРЕССИЯ НА БУДУЩЕЕ: _LATEX_SYMBOL_MAP — это plain str.replace() в порядке
# вставки словаря, а не regex. "\le" — подстрока "\leq", "\in" — подстрока
# "\infty" ("\in" + "fty"). Если порядок в словаре когда-нибудь поменяют так,
# что короткая команда окажется раньше длинной, начинающейся с той же
# подстроки, результат будет испорчен ("∈fty" вместо "∞" и т.п.). Здесь фикс
# ИМЕННО порядка (leq/geq/neq/infty перед le/ge/ne/in) — тест проверяет
# итоговое поведение, а не сам порядок словаря, поэтому переживёт рефакторинг,
# если он сохранит корректность.
assert lumen_formatting._scrub_latex(r"a \leq b \le c") == "a ≤ b ≤ c"
assert lumen_formatting._scrub_latex(r"a \geq b \ge c") == "a ≥ b ≥ c"
assert lumen_formatting._scrub_latex(r"a \neq b \ne c") == "a ≠ b ≠ c"
assert lumen_formatting._scrub_latex(r"x \in S, \infty") == "x ∈ S, ∞"
def test_scrub_latex_protected_inside_code_blocks_via_full_pipeline():
# Полный конвейер _md_to_html извлекает код ДО вызова _scrub_latex — обратные
# слэши в реальном коде (regex, пути Windows) не должны пострадать.
text = "```python\nimport re\npattern = re.compile(r\"\\d+\")\n```\nформула \\(\\pi r^2\\) вне кода."
result = lumen_formatting._md_to_html(text)
assert "\\d+" in result
assert "π r²" in result
# ─────────────────── нормализация маркеров списков "- "/"* " → "• " ───────────────────
# Регрессия на реальный найденный пробел: _md_to_html конвертирует **bold**/*italic*/
# `code`/таблицы, но раньше НЕ трогал обычные markdown-списки — они уходили в
# Telegram буквально с "-"/"*" в начале строки.
@pytest.mark.parametrize(("source", "expected"), [
("- Пункт один\n- Пункт два", "• Пункт один\n• Пункт два"),
("* Пункт один\n* Пункт два", "• Пункт один\n• Пункт два"),
(" - вложенный пункт", " • вложенный пункт"),
])
def test_normalize_bullet_markers(source, expected):
assert lumen_formatting._normalize_bullet_markers(source) == expected
def test_normalize_bullet_markers_does_not_touch_bold_at_line_start():
text = "**Жирный заголовок в начале строки**\nобычный текст"
assert lumen_formatting._normalize_bullet_markers(text) == text
def test_normalize_bullet_markers_does_not_touch_table_separator_row():
# Строка-разделитель таблицы ("---|---") не должна ошибочно приниматься за
# маркер списка — у неё нет пробела сразу после первого дефиса.
text = "Название | Цена\n---|---\nКофе | 150"
assert lumen_formatting._normalize_bullet_markers(text) == text
def test_split_inline_bullets_splits_long_run_into_lines():
# Прод 22.09.2026: модель написала весь список сравнения в один абзац через "•".
items = ["пункт %d с развёрнутым текстом для набора длины абзаца" % i for i in range(5)]
text = "Вот моменты сравнения характеристик: " + " • ".join(items)
assert len(text) >= 200
result = lumen_formatting._split_inline_bullets(text)
lines = result.split("\n")
assert lines[0] == "Вот моменты сравнения характеристик: " + items[0]
assert lines[1:] == ["• " + item for item in items[1:]]
def test_split_inline_bullets_ignores_short_prose():
# Короткие "чай • кофе" и два разделителя — обычная проза, не список.
assert lumen_formatting._split_inline_bullets("На выбор чай • кофе • сок.") == "На выбор чай • кофе • сок."
assert lumen_formatting._split_inline_bullets("Плюсы • минусы") == "Плюсы • минусы"
assert lumen_formatting._split_inline_bullets("На выбор чай • кофе.") == "На выбор чай • кофе."
@pytest.mark.parametrize(("text", "n_lines", "intro"), [
# Прод 26.09.2026: ровно 3 пункта (2 разделителя) — самый частый живой случай.
(
"• Рассеяние Рэлея: мелкие частицы в атмосфере рассеивают коротковолновый свет. "
"• Солнечный спектр: солнце излучает больше синего света днём и ночью. "
"• Отсутствие поглощения: газы атмосферы почти не поглощают синий свет.",
3, None,
),
# Прод 05.10.2026: склейка вообще без пробелов вокруг "•".
(
"• Отвечать на вопросы, вести беседу.• Выполнять веб-поиск и использовать полученные данные. "
"•Читать открытые веб-страницы и анализировать их содержание.• Анализировать присланные фото, видео и документы.",
4, None,
),
# Та же склейка маркером "·": другой символ, та же каша для читателя.
(
"Итоги такие: первый пункт с подробностями для длины строки и смыслом · второй пункт с подробностями "
"для длины строки и смыслом · третий пункт с подробностями для длины строки и смыслом · четвёртый пункт.",
4, "Итоги такие: ",
),
# Прод 07.10.2026: строки платформ (~150 символов) не ловились порогом 200.
(
"Telegram: • Поищи канал по названию и нажми вступить для проверки длины строки • Часто приватные каналы шлют приглашения в личку",
3, "Telegram:",
),
])
def test_split_inline_bullets_splits_prod_answers(text, n_lines, intro):
assert len(text) >= 120
lines = lumen_formatting._split_inline_bullets(text).split("\n")
assert len(lines) == n_lines
assert "" not in lines
if intro is None:
assert all(line.startswith("• ") for line in lines)
else:
assert lines[0].startswith(intro)
assert all(line.startswith("• ") for line in lines[1:])
def test_md_to_html_splits_inline_bullets_end_to_end():
items = ["тезис номер %d с подробным раскрытием мысли" % i for i in range(4)]
text = "Итоги сравнения моделей: " + " • ".join(items)
result = lumen_formatting._md_to_html(text)
assert result.count("\n• ") == 3
@pytest.mark.parametrize(("text", "intro"), [
# Прод 25.09.2026: модель написала "1. ... 2. ... 3. ..." одним абзацем.
(
"1. Рэлеевское рассеяние — молекулы воздуха рассеивают солнечный свет. "
"2. Зависимость от длины волны — короткие волны рассеиваются сильнее. "
"3. Восприятие глаза — глаз чувствительнее к синему цвету неба.",
None,
),
(
"Причины такие: 1. Первая причина с длинным пояснением текста. 2. Вторая причина с длинным пояснением текста. 3. Третья причина с длинным пояснением текста.",
"Причины такие:",
),
])
def test_split_inline_numbered_splits_glued_lists(text, intro):
lines = lumen_formatting._split_inline_numbered(text).split("\n")
if intro is None:
assert len(lines) == 3
assert lines[0].startswith("1. ") and lines[1].startswith("2. ") and lines[2].startswith("3. ")
else:
assert lines[0] == intro
assert [line[:2] for line in lines[1:]] == ["1.", "2.", "3."]
def test_split_inline_numbered_ignores_prose_and_versions():
# Два пункта без третьего, годы/версии, отсылка "пункты 1. и 2." — не списки.
assert lumen_formatting._split_inline_numbered("1. Да 2. Нет") == "1. Да 2. Нет"
assert lumen_formatting._split_inline_numbered("Версия 3.5 вышла в 1995 году.") == "Версия 3.5 вышла в 1995 году."
assert lumen_formatting._split_inline_numbered("Смотри пункты 1. и 2. ниже.") == "Смотри пункты 1. и 2. ниже."
code = "```\n1. Первый шаг алгоритма. 2. Второй шаг алгоритма. 3. Третий шаг алгоритма.\n```"
assert "1. Первый шаг алгоритма. 2." in lumen_formatting._md_to_html(code)
def test_md_to_html_splits_inline_numbered_end_to_end():
text = "1. Пункт первый с достаточным пояснением для проверки. 2. Пункт второй с достаточным пояснением для проверки. 3. Пункт третий с достаточным пояснением для проверки."
result = lumen_formatting._md_to_html(text)
assert "\n2. " in result and "\n3. " in result
def test_md_to_rich_html_splits_inline_lists_end_to_end():
# Rich-путь раньше не разносил слипшееся вообще (прод 25.09.2026: мелодрамы и вердикт одной строкой).
bullets = "Вот несколько хороших мелодрамм: " + " • ".join(
["фильм номер %d с тёплым и подробным описанием сюжета" % i for i in range(4)]
)
assert "<br/>• " in lumen_formatting._md_to_rich_html(bullets)
numbered = "1. Пункт первый с достаточным пояснением для проверки. 2. Пункт второй с достаточным пояснением для проверки. 3. Пункт третий с достаточным пояснением для проверки."
rich = lumen_formatting._md_to_rich_html(numbered)
assert "<br/>2. " in rich and "<br/>3. " in rich
def test_md_to_rich_html_scrubs_stray_latex_like_the_plain_path():
# Регрессия (аудит 26.09.2026): rich-путь оставлял сырой LaTeX вне разделителей
# ("\frac", "\alpha\times"), обычный переводил в юникод — один и тот же ответ
# выглядел по-разному в стриме и в финале.
b = chr(92)
html = lumen_formatting._md_to_html(f"S = {b}frac{{1}}{{2}}{b}pi r^2 and {b}alpha{b}times{b}beta")
rich = lumen_formatting._md_to_rich_html(f"S = {b}frac{{1}}{{2}}{b}pi r^2 and {b}alpha{b}times{b}beta")
assert html == rich
assert b not in rich, "сырые слэши в финальном сообщении — провал промта"
assert "1/2" in rich and "α" in rich
def test_md_to_rich_html_keeps_delimited_math_untouched():
# $...$ уходит в <tg-math> как есть (это единственное намеренное отличие rich
# от обычного пути, где та же формула становится юникодом).
rich = lumen_formatting._md_to_rich_html("скорость $x^2$ тут")
assert "<tg-math>x^2</tg-math>" in rich
def test_render_paths_parity_on_shared_cases():
# Параметрический тест на паритет двух путей рендера (обычный HTML и rich):
# расходиться могут только заголовки ($x$ → <tg-math>), всё остальное обязано
# совпадать, иначе стрим и финальное сообщение показывают разное.
b = chr(92)
shared = [
"Просто текст без разметки",
"**жирно** и *курсив* и `код`",
"- пункт один" + "\n" + "- пункт два",
"1. раз" + "\n" + "2. два",
"> цитата тут",
"текст с <b>сырым html</b>",
f"S = {b}pi r^2 и {b}sqrt{{9}}",
"список: " + " • ".join(f"пункт {i} с текстом подлиннее" for i in range(4)),
]
for case in shared:
# Перенос строки в rich — это <br/> (прод 07.10.2026: голый \n там склеивался),
# в обычном HTML — \n. Поэтому сравниваем с приведением к общему виду.
rich = lumen_formatting._md_to_rich_html(case).replace("<br/>", "\n")
assert lumen_formatting._md_to_html(case) == rich, case
def test_render_paths_differ_only_for_headings():
# Заголовки — единственное намеренное расхождение: rich умеет <h3>, обычный
# путь отдаёт <b>. Проверяем, что это всё, что разъезжается.
assert lumen_formatting._md_to_html("## Title") == "<b>Title</b>"
assert lumen_formatting._md_to_rich_html("## Title") == "<h3>Title</h3>"
def test_md_to_rich_html_turns_newlines_into_br():
# Прод 07.10.2026: в финальном rich-сообщении разнесённый список приходил одной
# кашей — Telegram склеивает абзацы, голый "\n" разрывом не считается. Разрыв
# в rich даёт только <br/> (так же в доках показан <blockquote>). Падает на
# старом коде, где переносы оставались "\n" и список выглядел слипшимся.
b = chr(96) * 3
text = "список:\n• раз\n• два\n\n" + b + "python\nx = 1\ny = 2\n" + b
rich = lumen_formatting._md_to_rich_html(text)
assert "список:<br/>• раз<br/>• два<br/><br/>" in rich
# Переносы внутри блока кода обязаны остаться переносами.
assert '<pre><code class="language-python">x = 1\ny = 2</code></pre>' in rich
# Вне <pre> голых переносов не остаётся.
assert "\n" not in rich.replace("x = 1\ny = 2", "")
def test_md_to_rich_html_splits_three_bullets_prod_movies():
# Прод 26.09.2026: ответ про мелодрамы — 3 пункта склеились, rich-путь молчал.
text = (
"• Амелі (2001): французская камедыя-фэнтэзі пра маладых, якія бачаць свет у яркіх фарбах. "
"• Падынгтан (2014): лёгкі сямейны фільм пра мілога мядзведзя, які шукае новы дом у Лондане. "
"• Crazy Rich Asians (2018): вясёлая рамантычная камедыя пра кітайскую эліту, поўная колеру і музыкі."
)
assert len(text) >= 200
assert "<br/>• " in lumen_formatting._md_to_rich_html(text)
def test_md_to_html_full_pipeline_converts_bullet_list_with_bold():
text = "* **Возмездие:** аргумент про справедливость\n* **Сдерживание:** снижает преступность"
result = lumen_formatting._md_to_html(text)
assert result.startswith("• <b>Возмездие:</b>")
assert "\n• <b>Сдерживание:</b>" in result
assert "*" not in result.replace("</b>", "").replace("<b>", "")
# ─────────────── markdown-заголовки "#"/"##"/"###" → **жирный текст** ───────────────
# РЕГРЕССИЯ, найденная на реальных скриншотах Telegram (18 августа 2026):
# system_prompt.py запрещает markdown-заголовки, но модели (особенно бесплатные
# модели OpenRouter) регулярно их всё равно пишут — раньше "###" уходило в
# Telegram буквально, без единой защитной сетки (в отличие от таблиц/списков).
@pytest.mark.parametrize(("source", "expected"), [
("### Как она выводится?", "**Как она выводится?**"),
("# Заголовок", "**Заголовок**"),
("## Подзаголовок", "**Подзаголовок**"),
])
def test_normalize_headers_converts_atx_to_bold(source, expected):
assert lumen_formatting._normalize_headers(source) == expected
def test_normalize_headers_does_not_touch_hash_mid_line():
# "C#" / "#tag" не в начале строки — ATX-заголовок ТОЛЬКО в начале строки.
text = "Язык C# отличается от C++.\nПодробнее: #tag"
assert lumen_formatting._normalize_headers(text) == text
def test_normalize_headers_requires_space_after_hashes():
# "#без_пробела" — не заголовок по правилам CommonMark ATX, не трогаем.
assert lumen_formatting._normalize_headers("#без_пробела текст") == "#без_пробела текст"
def test_normalize_headers_bare_hashes_with_no_text_removed():
assert lumen_formatting._normalize_headers("### \nследующая строка") == "\nследующая строка"
def test_normalize_headers_does_not_double_wrap_already_bold_content():
# "### **Важно**" — модель сама уже обернула текст в bold; повторная обёртка
# дала бы "****Важно****" и сломала бы парность "**" в Phase 3.
assert lumen_formatting._normalize_headers("### **Важно**") == "**Важно**"
def test_md_to_html_full_pipeline_strips_stray_header_markers():
# Регрессия на реальный найденный баг: пользователь видел буквальные "###" в
# сообщении бота вместо жирного текста — полный конвейер должен это исправлять.
result = lumen_formatting._md_to_html("### Как она выводится?\nобычный текст")
assert "###" not in result
assert result.startswith("<b>Как она выводится?</b>")
def test_md_to_html_header_hash_inside_code_block_untouched():
# "#" внутри блока кода (например, комментарий Python) не должен считаться
# заголовком — код уже вынесен плейсхолдером до этой фазы.
text = "```python\n# обычный комментарий\nprint(1)\n```"
result = lumen_formatting._md_to_html(text)
assert "# обычный комментарий" in result
assert "<b>" not in result
# ─────────────────── spoiler-тег: защитная сетка (тот же принцип, что <u>) ───────────────────
@pytest.mark.parametrize(("source", "expected"), [
("<tg-spoiler>секрет</tg-spoiler>", "секрет"),
('<span class="tg-spoiler">секрет</span>', "секрет"),
])
def test_md_to_html_strips_literal_spoiler_tags(source, expected):
assert lumen_formatting._md_to_html(source) == expected
# ─────────────────── подсветка синтаксиса: язык из ```fence сохраняется ───────────────────
# (сам кейс с языком покрыт test_md_to_html_code_block выше)
def test_md_to_html_code_block_without_language_unchanged():
assert lumen_formatting._md_to_html("```\nprint(1)\n```") == "<pre>print(1)</pre>"
# ─────────────────── markdown-цитаты "> " → <blockquote> ───────────────────
@pytest.mark.parametrize(("source", "expected"), [
("> цитата", "<blockquote>цитата</blockquote>"),
("> первая строка\n> вторая строка", "<blockquote>первая строка\nвторая строка</blockquote>"),
])
def test_md_to_html_converts_blockquotes(source, expected):
assert lumen_formatting._md_to_html(source) == expected
def test_md_to_html_blockquote_markdown_inside_still_converts():
result = lumen_formatting._md_to_html("> **важно**: не забудь")
assert result == "<blockquote><b>важно</b>: не забудь</blockquote>"
def test_md_to_html_blockquote_only_affects_quoted_lines():
result = lumen_formatting._md_to_html("обычный текст\n> цитата\nещё текст")
assert result == "обычный текст\n<blockquote>цитата</blockquote>\nещё текст"
def test_md_to_html_no_false_positive_on_greater_than_sign():
# "5 > 3" — обычное сравнение, не в начале строки — не должно стать цитатой.
assert lumen_formatting._md_to_html("сравнение: 5 > 3") == "сравнение: 5 > 3"
# ─────────────────── markdown-ссылки [текст](url) → <a href="url">текст</a> ───────────────────
def test_md_to_html_converts_markdown_link():
result = lumen_formatting._md_to_html("[почитать здесь](https://example.com/page)")
assert result == '<a href="https://example.com/page">почитать здесь</a>'
def test_md_to_html_replaces_mismatched_url_label():
# Аудит 28.10.2026: метка-URL на чужой хост — готовый фишинг.
html = lumen_formatting._md_to_html("[https://bank.example](https://attacker.example/x)")
assert ">https://attacker.example/x<" in html
assert "bank.example" not in html
# Обычная текстовая метка не трогается.
assert ">Reuters<" in lumen_formatting._md_to_html("[Reuters](https://reuters.com/x)")
def test_md_to_html_link_with_underscores_in_url_not_corrupted_by_italic():
# Регрессия: URL с двумя "_" мог бы ошибочно засчитаться за пару italic-
# маркеров, если бы конвертация ссылок шла раньше bold/italic в Phase 3.
result = lumen_formatting._md_to_html("[текст](https://example.com/foo_bar_baz)")
assert result == '<a href="https://example.com/foo_bar_baz">текст</a>'
assert "<i>" not in result
def test_md_to_html_link_text_markdown_not_processed_intentionally():
# Текст ссылки вырезается плейсхолдером в Phase 1 (см. комментарий в коде) —
# markdown внутри него намеренно не поддерживается (не запрашивалось), выходит
# как обычный экранированный текст, а не корёжится и не превращается в <b>.
result = lumen_formatting._md_to_html("[**жирная ссылка**](https://example.com)")
assert result == '<a href="https://example.com">**жирная ссылка**</a>'
def test_md_to_html_non_http_bracket_text_left_alone():
# "[note]" без http(s)-ссылки — не markdown-ссылка, не должно превращаться в <a>.
assert lumen_formatting._md_to_html("текст [note] продолжение") == "текст [note] продолжение"
def test_md_to_html_link_url_with_quote_is_escaped():
result = lumen_formatting._md_to_html('[текст](https://example.com/"injected)')
assert '"' in result
assert '"injected' not in result
# ─────────────────── _md_to_rich_html (Bot API 10.1+, sendRichMessage) ───────────────────
# Тот же конвейер, что _md_to_html, но таблицы/заголовки/LaTeX идут настоящими
# рич-тегами — сервер Telegram рендерит их сам. Тесты фиксируют контракт,
# от которого зависит rich-отправка в bot.py.
def test_rich_table_renders_bordered_table():
result = lumen_formatting._md_to_rich_html("| A | B |\n|---|---|\n| 1 | 2 |")
assert result == "<table bordered><tr><th>A</th><th>B</th></tr><tr><td>1</td><td>2</td></tr></table>"
def test_rich_table_cells_support_inline_markup_and_escape():
result = lumen_formatting._md_to_rich_html("| H | K |\n|---|---|\n| **b** <x> | 2 |")
assert result == "<table bordered><tr><th>H</th><th>K</th></tr><tr><td><b>b</b> <x></td><td>2</td></tr></table>"
def test_rich_non_table_pipes_left_alone():
# Одиночные "|" без строки-разделителя — не таблица, текст как есть.
assert lumen_formatting._md_to_rich_html("a | b") == "a | b"
@pytest.mark.parametrize(("source", "expected"), [
("## Заголовок", "<h3>Заголовок</h3>"),
("### Подзаголовок", "<h4>Подзаголовок</h4>"),
# Тот же допуск, что у легаси-нормализации: ведущие пробелы и 1–6 решёток.
(" ## Заголовок", "<h3>Заголовок</h3>"),
("# Топ", "<h2>Топ</h2>"),
("#### Глубокий", "<h4>Глубокий</h4>"),
])
def test_rich_headings_become_h_tags(source, expected):
assert lumen_formatting._md_to_rich_html(source) == expected
@pytest.mark.parametrize(("source", "expected"), [
("корень $x^2$ тут", "корень <tg-math>x^2</tg-math> тут"),
("формула $$E=mc^2$$ конец", "формула <tg-math-block>E=mc^2</tg-math-block> конец"),
# Скобочные формы LaTeX (реальный кейс nemotron) извлекаются раньше $.
("смотри \\[S = \\pi r^{2}\\] конец", "смотри <tg-math-block>S = \\pi r^{2}</tg-math-block> конец"),
("значение \\(x\\) тут", "значение <tg-math>x</tg-math> тут"),
])
def test_rich_math_preserved_as_tg_math_tags(source, expected):
# В отличие от _md_to_html (юникод-замена), рич-путь отдаёт LaTeX сырым —
# рендерит сервер Telegram.
assert lumen_formatting._md_to_rich_html(source) == expected
def test_rich_keeps_bold_links_code_quotes_and_escape():
assert lumen_formatting._md_to_rich_html("**b**") == "<b>b</b>"
assert lumen_formatting._md_to_rich_html("[t](https://example.com/a_b)") == '<a href="https://example.com/a_b">t</a>'
assert lumen_formatting._md_to_rich_html("`<x>`") == "<code><x></code>"
assert lumen_formatting._md_to_rich_html("5 < 10") == "5 < 10"
assert lumen_formatting._md_to_rich_html("> цитата") == "<blockquote>цитата</blockquote>"
def test_rich_math_inside_code_stays_code():
# LaTeX внутри кода — код, а не формула (порядок экстракции: код раньше математики).
assert lumen_formatting._md_to_rich_html("`$x$`") == "<code>$x$</code>"
def test_br_tag_becomes_line_break_in_both_paths():
# Прод-кейс 17.09.2026: модель пишет "<br>" внутри ячеек таблиц — без
# обработки долетает до escape и светится буквально ("...сгор.<br>Pro Max").
assert lumen_formatting._md_to_html("a<br>b") == "a\nb"
assert lumen_formatting._md_to_html("a<br/>b") == "a\nb"
assert lumen_formatting._md_to_rich_html("| H | K |\n|---|---|\n| a<br>b | 2 |") == (
"<table bordered><tr><th>H</th><th>K</th></tr><tr><td>a<br/>b</td><td>2</td></tr></table>"
)
# "<blockquote>" не должен съедаться строгим паттерном <br>.
assert "blockquote" in lumen_formatting._md_to_html("<blockquote>ц</blockquote>")
def test_strip_markdown_removes_syntax_keeping_words():
assert lumen_formatting._strip_markdown("**жирный** и *курсив*") == "жирный и курсив"
assert lumen_formatting._strip_markdown("код `x=1` тут") == "код x=1 тут"
assert lumen_formatting._strip_markdown("[текст](https://example.com)") == "текст"
assert lumen_formatting._strip_markdown("## Заголовок") == "Заголовок"
assert lumen_formatting._strip_markdown("~~чист~~") == "чист"
assert lumen_formatting._strip_markdown("") == ""
assert lumen_formatting._strip_markdown("обычный текст 5 < 10") == "обычный текст 5 < 10"
def test_rich_prices_are_not_treated_as_math():
# "$50 до $100" — контент с пробелом у границы, не формула. Одиночный знак
# без пары — тоже текст.
assert lumen_formatting._md_to_rich_html("от $50 до $100") == "от $50 до $100"
assert lumen_formatting._md_to_rich_html("всего $80 000") == "всего $80 000"
assert lumen_formatting._md_to_rich_html("$x$") == "<tg-math>x</tg-math>"
def test_inline_splitters_leave_structural_lines_alone():
# Регрессия (враждебное ревью 27.09.2026): разнос слипшихся списков ел цитаты,
# заголовки и строки markdown-таблиц — пользователь видел пустую плашку цитаты,
# торчащее "##" и разорванную таблицу.
quoted = "> 1. alpharazat elfin uvicorn 2. betakakt elfin uvicorn 3. gamakakt elfin uvicorn"
out = lumen_formatting._md_to_html(quoted)
assert out.startswith("<blockquote>") and out.endswith("</blockquote>")
assert "1." in out, f"пункты должны остаться внутри цитаты: {out!r}"
assert "1." in lumen_formatting._md_to_rich_html(quoted)
head = "## 1. alpharazat elfin uvicorn 2. betakakt elfin uvicorn 3. gamakakt elfin uvicorn"
assert "##" not in lumen_formatting._md_to_html(head)
rich_head = lumen_formatting._md_to_rich_html(head)
assert rich_head.startswith("<h3>") and "\n" not in rich_head, rich_head
table = "| A | B |\n|---|---|\n| 1. alpharazat elfin uvicorn 2. betakakt elfin uvicorn 3. gamakakt elfin uvicorn | four |"
assert "<b>A:</b>" in lumen_formatting._md_to_html(table), "разнос разорвал строку таблицы"
rich_table = lumen_formatting._md_to_rich_html(table)
assert "<table" in rich_table and "<td>1. alpharazat elfin uvicorn 2. betakakt elfin uvicorn 3. gamakakt elfin uvicorn</td>" in rich_table
# Таблица без внешних пайпов: строка не начинается с `|`, но разносчик обязан
# узнать блок таблицы, иначе HTML-путь рвал её до детекта, а rich — нет.
bare_table = "Name | Age\n---|---\n1. alpharazat elfin uvicorn 2. betakakt elfin uvicorn 3. gamakakt elfin uvicorn | four"
html_bare = lumen_formatting._md_to_html(bare_table)
assert "<b>Name:</b>" in html_bare and "---" not in html_bare, html_bare
rich_bare = lumen_formatting._md_to_rich_html(bare_table)
assert "<table" in rich_bare and "1. alpharazat elfin uvicorn 2. betakakt elfin uvicorn 3. gamakakt elfin uvicorn" in rich_bare
# Буллеты тоже не лезут в служебные строки (длина обязана превышать порог 200).
long_items = " • ".join(f"пункт номер {i} с достаточно длинным описанием" for i in range(4))
for structural in (f"> вводная {long_items}", f"## вводная {long_items}", f"| вводная {long_items} | конец |"):
assert lumen_formatting._split_inline_bullets(structural) == structural, structural[:60]
long_numbered = "1. alpharazat elfin uvicorn 2. betakakt elfin uvicorn 3. gamakakt elfin uvicorn"
for structural in (f"> {long_numbered}", f"## {long_numbered}"):
assert "\n" not in lumen_formatting._split_inline_numbered(structural), structural[:60]
def test_inline_splitters_still_split_prose():
# Граница правки: обычная проза по-прежнему разносится.
prose = "1. alpharazat elfin uvicorn 2. betakakt elfin uvicorn 3. gamakakt elfin uvicorn"
assert lumen_formatting._split_inline_numbered(prose).count("\n") == 2
assert lumen_formatting._split_inline_bullets(prose) == prose
def test_scrub_latex_int_is_not_eaten_by_in():
# Регрессия (враждебное ревью 27.09.2026): в карте символов \in шёл раньше \int,
# поэтому "\int_0^1" превращался в "∈t₀¹".
assert lumen_formatting._scrub_latex(r"\int_0^1 x dx") == "∫₀¹ x dx"
assert lumen_formatting._scrub_latex(r"a \in b") == "a ∈ b"
assert lumen_formatting._scrub_latex(r"\infty") == "∞"
def test_rich_heading_keeps_scrubbed_latex():
# Регрессия (враждебное ревью 27.09.2026): заголовок уходил в плейсхолдер ДО
# общего _scrub_latex, поэтому "\alpha" оставался сырым в <h3>.
out = lumen_formatting._md_to_rich_html("## Коэффициент \\alpha и \\int_0^1")
assert "\\alpha" not in out and "\\int" not in out
assert "α" in out and "∫" in out
|