`") == "<tag>"
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("") == result.count("")
assert result.count("") == result.count("")
assert result.count("") == result.count("")
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 "" 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 '' in html
assert "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():
# Регрессия: модель иногда пишет литеральные / теги вместо markdown
# (несмотря на инструкцию в system_prompt.py) — раньше это экранировалось
# и показывалось пользователю как видимый мусорный текст вида "".
assert lumen_formatting._md_to_html("Заголовок") == "Заголовок"
def test_md_to_html_strips_broken_self_closing_tag():
# Реальный найденный баг: модель иногда пишет невалидный self-closing "".
result = lumen_formatting._md_to_html("текст ещё текст")
assert "" 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("код: print(1)") == "код: print(1)"
result = lumen_formatting._md_to_html("def f():\n pass
")
assert result.startswith("") and result.endswith("")
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"
"| Сообщество | Огромное | Среднее |",
("Аспект: Кривая обучения", "React: Высокая", "Vue: Низкая"),
),
# Некоторые модели пишут таблицы без внешних "|" по краям строки.
(
"Название | Цена\n"
"---|---\n"
"Кофе | 150\n"
"Чай | 100",
("Название: Кофе", "Цена: 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 "
• " in lumen_formatting._md_to_rich_html(bullets)
numbered = "1. Пункт первый с достаточным пояснением для проверки. 2. Пункт второй с достаточным пояснением для проверки. 3. Пункт третий с достаточным пояснением для проверки."
rich = lumen_formatting._md_to_rich_html(numbered)
assert "
2. " in rich and "
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():
# $...$ уходит в как есть (это единственное намеренное отличие rich
# от обычного пути, где та же формула становится юникодом).
rich = lumen_formatting._md_to_rich_html("скорость $x^2$ тут")
assert "x^2" in rich
def test_render_paths_parity_on_shared_cases():
# Параметрический тест на паритет двух путей рендера (обычный HTML и rich):
# расходиться могут только заголовки ($x$ → ), всё остальное обязано
# совпадать, иначе стрим и финальное сообщение показывают разное.
b = chr(92)
shared = [
"Просто текст без разметки",
"**жирно** и *курсив* и `код`",
"- пункт один" + "\n" + "- пункт два",
"1. раз" + "\n" + "2. два",
"> цитата тут",
"текст с сырым html",
f"S = {b}pi r^2 и {b}sqrt{{9}}",
"список: " + " • ".join(f"пункт {i} с текстом подлиннее" for i in range(4)),
]
for case in shared:
# Перенос строки в rich — это
(прод 07.10.2026: голый \n там склеивался),
# в обычном HTML — \n. Поэтому сравниваем с приведением к общему виду.
rich = lumen_formatting._md_to_rich_html(case).replace("
", "\n")
assert lumen_formatting._md_to_html(case) == rich, case
def test_render_paths_differ_only_for_headings():
# Заголовки — единственное намеренное расхождение: rich умеет , обычный
# путь отдаёт . Проверяем, что это всё, что разъезжается.
assert lumen_formatting._md_to_html("## Title") == "Title"
assert lumen_formatting._md_to_rich_html("## Title") == "Title
"
def test_md_to_rich_html_turns_newlines_into_br():
# Прод 07.10.2026: в финальном rich-сообщении разнесённый список приходил одной
# кашей — Telegram склеивает абзацы, голый "\n" разрывом не считается. Разрыв
# в rich даёт только
(так же в доках показан ). Падает на
# старом коде, где переносы оставались "\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 "список:
• раз
• два
" in rich
# Переносы внутри блока кода обязаны остаться переносами.
assert 'x = 1\ny = 2
' in rich
# Вне голых переносов не остаётся.
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 "
• " 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("• Возмездие:")
assert "\n• Сдерживание:" in result
assert "*" not in result.replace("
", "").replace("", "")
# ─────────────── 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("Как она выводится?")
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 "" not in result
# ─────────────────── spoiler-тег: защитная сетка (тот же принцип, что ) ───────────────────
@pytest.mark.parametrize(("source", "expected"), [
("секрет", "секрет"),
('секрет', "секрет"),
])
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```") == "print(1)
"
# ─────────────────── markdown-цитаты "> " → ───────────────────
@pytest.mark.parametrize(("source", "expected"), [
("> цитата", "цитата
"),
("> первая строка\n> вторая строка", "первая строка\nвторая строка
"),
])
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 == "важно: не забудь
"
def test_md_to_html_blockquote_only_affects_quoted_lines():
result = lumen_formatting._md_to_html("обычный текст\n> цитата\nещё текст")
assert result == "обычный текст\nцитата
\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) → текст ───────────────────
def test_md_to_html_converts_markdown_link():
result = lumen_formatting._md_to_html("[почитать здесь](https://example.com/page)")
assert result == 'почитать здесь'
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 == 'текст'
assert "" not in result
def test_md_to_html_link_text_markdown_not_processed_intentionally():
# Текст ссылки вырезается плейсхолдером в Phase 1 (см. комментарий в коде) —
# markdown внутри него намеренно не поддерживается (не запрашивалось), выходит
# как обычный экранированный текст, а не корёжится и не превращается в .
result = lumen_formatting._md_to_html("[**жирная ссылка**](https://example.com)")
assert result == '**жирная ссылка**'
def test_md_to_html_non_http_bracket_text_left_alone():
# "[note]" без http(s)-ссылки — не markdown-ссылка, не должно превращаться в .
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 == ""
def test_rich_table_cells_support_inline_markup_and_escape():
result = lumen_formatting._md_to_rich_html("| H | K |\n|---|---|\n| **b** | 2 |")
assert result == ""
def test_rich_non_table_pipes_left_alone():
# Одиночные "|" без строки-разделителя — не таблица, текст как есть.
assert lumen_formatting._md_to_rich_html("a | b") == "a | b"
@pytest.mark.parametrize(("source", "expected"), [
("## Заголовок", "Заголовок
"),
("### Подзаголовок", "Подзаголовок
"),
# Тот же допуск, что у легаси-нормализации: ведущие пробелы и 1–6 решёток.
(" ## Заголовок", "Заголовок
"),
("# Топ", "Топ
"),
("#### Глубокий", "Глубокий
"),
])
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$ тут", "корень x^2 тут"),
("формула $$E=mc^2$$ конец", "формула E=mc^2 конец"),
# Скобочные формы LaTeX (реальный кейс nemotron) извлекаются раньше $.
("смотри \\[S = \\pi r^{2}\\] конец", "смотри S = \\pi r^{2} конец"),
("значение \\(x\\) тут", "значение x тут"),
])
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"
assert lumen_formatting._md_to_rich_html("[t](https://example.com/a_b)") == 't'
assert lumen_formatting._md_to_rich_html("``") == "<x>"
assert lumen_formatting._md_to_rich_html("5 < 10") == "5 < 10"
assert lumen_formatting._md_to_rich_html("> цитата") == "цитата
"
def test_rich_math_inside_code_stays_code():
# LaTeX внутри кода — код, а не формула (порядок экстракции: код раньше математики).
assert lumen_formatting._md_to_rich_html("`$x$`") == "$x$"
def test_br_tag_becomes_line_break_in_both_paths():
# Прод-кейс 17.09.2026: модель пишет "
" внутри ячеек таблиц — без
# обработки долетает до escape и светится буквально ("...сгор.
Pro Max").
assert lumen_formatting._md_to_html("a
b") == "a\nb"
assert lumen_formatting._md_to_html("a
b") == "a\nb"
assert lumen_formatting._md_to_rich_html("| H | K |\n|---|---|\n| a
b | 2 |") == (
""
)
# "" не должен съедаться строгим паттерном
.
assert "blockquote" in lumen_formatting._md_to_html("ц
")
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$") == "x"
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("") and out.endswith("
")
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("") 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 "A:" in lumen_formatting._md_to_html(table), "разнос разорвал строку таблицы"
rich_table = lumen_formatting._md_to_rich_html(table)
assert "
1. alpharazat elfin uvicorn 2. betakakt elfin uvicorn 3. gamakakt elfin uvicorn" 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 "Name:" in html_bare and "---" not in html_bare, html_bare
rich_bare = lumen_formatting._md_to_rich_html(bare_table)
assert " вводная {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" оставался сырым в .
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