SilverElixir commited on
Commit
87f3899
·
1 Parent(s): 3812cc7

Merge near-duplicate formatting tests into parametrized cases

Browse files
Files changed (1) hide show
  1. tests/test_lumen_formatting.py +132 -157
tests/test_lumen_formatting.py CHANGED
@@ -15,6 +15,7 @@ Telegram/Gemini/OpenRouter/рантайм-состояния бота, поэт
15
  pytest test_lumen_formatting.py -v
16
  """
17
  import lumen_formatting
 
18
 
19
 
20
 
@@ -34,20 +35,15 @@ def test_truncate_html_to_fit_keeps_short_text_and_cuts_at_source_boundary():
34
 
35
  # ─────────────────────────── _md_to_html ───────────────────────────
36
 
37
- def test_md_to_html_empty_string():
38
- assert lumen_formatting._md_to_html("") == ""
39
-
40
-
41
- def test_md_to_html_bold():
42
- assert lumen_formatting._md_to_html("**bold**") == "<b>bold</b>"
43
-
44
-
45
- def test_md_to_html_italic():
46
- assert lumen_formatting._md_to_html("*italic*") == "<i>italic</i>"
47
-
48
-
49
- def test_md_to_html_strikethrough():
50
- assert lumen_formatting._md_to_html("~~strike~~") == "<s>strike</s>"
51
 
52
 
53
  def test_md_to_html_escapes_raw_html():
@@ -61,10 +57,7 @@ def test_md_to_html_inline_code():
61
 
62
 
63
  def test_md_to_html_code_block():
64
- # Язык фенса теперь сохраняется как class="language-x" (подсветка синтаксиса
65
- # в Telegram) — см. test_md_to_html_code_block_preserves_language_for_syntax_
66
- # highlighting ниже; без языка поведение как раньше (test_md_to_html_code_
67
- # block_without_language_unchanged).
68
  assert lumen_formatting._md_to_html("```python\nprint(1)\n```") == '<pre><code class="language-python">print(1)</code></pre>'
69
 
70
 
@@ -109,36 +102,30 @@ def test_md_to_html_still_escapes_ordinary_comparison_operators():
109
  assert lumen_formatting._md_to_html("сравнение: 5 < 10") == "сравнение: 5 &lt; 10"
110
 
111
 
112
- def test_md_to_html_converts_markdown_table_to_bullet_list():
113
- # Регрессия на реальный найденный при тестировании баг: Telegram не рендерит
114
- # markdown-таблицы НИ В КАКОМ режиме — пользователь видел сырой текст с "|" и
115
- # "---" вместо аккуратной таблицы (подтверждено скриншотами реального теста).
116
- text = (
117
  "| Аспект | React | Vue |\n"
118
  "|--------|-------|-----|\n"
119
  "| Кривая обучения | Высокая | Низкая |\n"
120
- "| Сообщество | Огромное | Среднее |"
121
- )
122
- result = lumen_formatting._md_to_html(text)
123
- assert "|" not in result
124
- assert "<b>Аспект:</b> Кривая обучения" in result
125
- assert "<b>React:</b> Высокая" in result
126
- assert "<b>Vue:</b> Низкая" in result
127
- assert result.count("•") == 2
128
-
129
-
130
- def test_md_to_html_table_without_outer_pipes_still_converted():
131
  # Некоторые модели пишут таблицы без внешних "|" по краям строки.
132
- text = (
133
  "Название | Цена\n"
134
  "---|---\n"
135
  "Кофе | 150\n"
136
- "Чай | 100"
137
- )
 
 
 
138
  result = lumen_formatting._md_to_html(text)
139
  assert "|" not in result
140
- assert "<b>Название:</b> Кофе" in result
141
- assert "<b>Цена:</b> 150" in result
142
 
143
 
144
  def test_md_to_html_does_not_touch_pipes_inside_code_block():
@@ -163,22 +150,24 @@ def test_md_to_html_no_false_positive_on_plain_text_with_dashes():
163
  # выдала "\[ S = \pi r^{2}, \]" и "\(x^{2}+y^{2}=r^{2}\)" вместо юникода, несмотря на
164
  # явный запрет LaTeX в system_prompt.py.
165
 
166
- def test_scrub_latex_converts_bracket_delimiters_and_pi_and_superscript():
167
- result = lumen_formatting._scrub_latex(r"Площадь: \[ S = \pi r^{2} \]")
168
- assert "\\[" not in result and "\\]" not in result
169
- assert "π" in result
170
- assert "r²" in result
171
-
 
 
172
 
173
- def test_scrub_latex_converts_paren_delimiters():
174
- result = lumen_formatting._scrub_latex(r"формула \(x^{2}+y^{2}=r^{2}\)")
175
- assert "\\(" not in result and "\\)" not in result
176
- assert "x²+y²=r²" in result
177
 
178
-
179
- def test_scrub_latex_converts_frac_and_sqrt():
180
- assert lumen_formatting._scrub_latex(r"\frac{1}{2}") == "1/2"
181
- assert lumen_formatting._scrub_latex(r"\sqrt{16}") == "√16"
 
 
 
 
182
 
183
 
184
  def test_scrub_latex_converts_common_symbols():
@@ -188,10 +177,6 @@ def test_scrub_latex_converts_common_symbols():
188
  assert "×" in result and "±" in result and "≤" in result and "≥" in result and "∞" in result
189
 
190
 
191
- def test_scrub_latex_noop_when_no_backslash_or_dollar():
192
- assert lumen_formatting._scrub_latex("обычный текст без формул") == "обычный текст без формул"
193
-
194
-
195
  def test_scrub_latex_does_not_confuse_currency_with_math_delimiters():
196
  # РЕГРЕССИЯ, найденная при code-review (25 июля 2026): первая версия скрубера
197
  # обрабатывала и одиночный "$...$" как инлайн-LaTeX. Если в одном сообщении
@@ -201,17 +186,13 @@ def test_scrub_latex_does_not_confuse_currency_with_math_delimiters():
201
  # ("цена 100, а формула x²$ рядом"). Одиночный "$" теперь не обрабатывается
202
  # вообще — только "$$...$$". Сами доллары остаются нетронутыми в обоих случаях;
203
  # "x^2" внутри всё равно аккуратно превращается в "x²" — это отдельная, не
204
- # завязанная на "$"-разделители замена (см. следующий тест), она безвредна и
205
- # здесь, и вне контекста "$".
206
  assert lumen_formatting._scrub_latex("цена $100, а формула $x^2$ рядом") == "цена $100, а формула $x²$ рядом"
207
  assert lumen_formatting._scrub_latex("первый вариант — $50, второй — $100") == "первый вариант — $50, второй — $100"
208
  assert lumen_formatting._scrub_latex("стоимость: $100. Итого: $200.") == "стоимость: $100. Итого: $200."
209
 
210
 
211
- def test_scrub_latex_still_converts_double_dollar_display_math():
212
- assert lumen_formatting._scrub_latex("$$x^2 + y^2$$") == "x² + y²"
213
-
214
-
215
  def test_scrub_latex_order_sensitive_replacements_dont_corrupt_each_other():
216
  # РЕГРЕССИЯ НА БУДУЩЕЕ: _LATEX_SYMBOL_MAP — это plain str.replace() в порядке
217
  # вставки словаря, а не regex. "\le" — подстрока "\leq", "\in" — подстрока
@@ -241,13 +222,13 @@ def test_scrub_latex_protected_inside_code_blocks_via_full_pipeline():
241
  # `code`/таблицы, но раньше НЕ трогал обычные markdown-списки — они уходили в
242
  # Telegram буквально с "-"/"*" в начале строки.
243
 
244
- def test_normalize_bullet_markers_converts_dash_and_asterisk():
245
- assert lumen_formatting._normalize_bullet_markers("- Пункт один\n- Пункт два") == "• Пункт один\n• Пункт два"
246
- assert lumen_formatting._normalize_bullet_markers("* Пункт один\n* Пункт два") == "• Пункт один\n• Пункт два"
247
-
248
-
249
- def test_normalize_bullet_markers_preserves_indentation():
250
- assert lumen_formatting._normalize_bullet_markers(" - вложенный пункт") == " • вложенный пункт"
251
 
252
 
253
  def test_normalize_bullet_markers_does_not_touch_bold_at_line_start():
@@ -280,45 +261,37 @@ def test_split_inline_bullets_ignores_short_prose():
280
  assert lumen_formatting._split_inline_bullets("На выбор чай • кофе.") == "На выбор чай • кофе."
281
 
282
 
283
- def test_split_inline_bullets_splits_three_item_prod_answer():
284
- # Прод 26.09.2026: ровно 3 пункта (2 разделителя) — самый частый живой случай,
285
- # со старым порогом 3 не ловился вообще (ответы про небо и мелодрамы).
286
- text = (
287
  "• Рассеяние Рэлея: мелкие частицы в атмосфере рассеивают коротковолновый свет. "
288
  "• Солнечный спектр: солнце излучает больше синего света днём и ночью. "
289
- "• Отсутствие поглощения: газы атмосферы почти не поглощают синий свет."
290
- )
291
- assert len(text) >= 200
292
- lines = lumen_formatting._split_inline_bullets(text).split("\n")
293
- assert len(lines) == 3
294
- assert all(line.startswith("• ") for line in lines)
295
-
296
-
297
- def test_split_inline_bullets_splits_glued_without_spaces():
298
- # Прод 05.10.2026: скриншот — модель склеила список способностей вообще без
299
- # пробелов вокруг "•", старая сетка на " • " его не видела.
300
- text = (
301
  "• Отвечать на вопросы, вести беседу.• Выполнять веб-поиск и использовать полученные данные. "
302
- "•Читать открытые веб-страницы и анализировать их содержание.• Анализировать присланные фото, видео и документы."
303
- )
304
- assert len(text) >= 200
305
- lines = lumen_formatting._split_inline_bullets(text).split("\n")
306
- assert len(lines) == 4
307
- assert all(line.startswith("• ") for line in lines)
308
- assert "" not in lines
309
-
310
-
311
- def test_split_inline_bullets_splits_middot_variant():
312
  # Та же склейка маркером "·": другой символ, та же каша для читателя.
313
- text = (
314
  "Итоги такие: первый пункт с подробностями для длины строки и смыслом · второй пункт с подробностями "
315
- "для длины строки и смыслом · третий пункт с подробностями для длины строки и смыслом · четвёртый пункт."
316
- )
 
 
 
317
  assert len(text) >= 200
318
  lines = lumen_formatting._split_inline_bullets(text).split("\n")
319
- assert len(lines) == 4
320
- assert lines[0].startswith("Итоги такие: ")
321
- assert all(line.startswith("• ") for line in lines[1:])
 
 
 
 
322
 
323
 
324
  def test_md_to_html_splits_inline_bullets_end_to_end():
@@ -328,23 +301,27 @@ def test_md_to_html_splits_inline_bullets_end_to_end():
328
  assert result.count("\n• ") == 3
329
 
330
 
331
- def test_split_inline_numbered_splits_glued_sky_answer():
332
  # Прод 25.09.2026: модель написала "1. ... 2. ... 3. ..." одним абзацем.
333
- text = (
334
  "1. Рэлеевское рассеяние — молекулы воздуха рассеивают солнечный свет. "
335
  "2. Зависимость от длины волны — короткие волны рассеиваются сильнее. "
336
- "3. Восприятие гл��за — глаз чувствительнее к синему цвету неба."
337
- )
338
- lines = lumen_formatting._split_inline_numbered(text).split("\n")
339
- assert len(lines) == 3
340
- assert lines[0].startswith("1. ") and lines[1].startswith("2. ") and lines[2].startswith("3. ")
341
-
342
-
343
- def test_split_inline_numbered_keeps_intro_on_own_line():
344
- text = "Причины такие: 1. Первая причина с длинным пояснением текста. 2. Вторая причина с длинным пояснением текста. 3. Третья причина с длинным пояснением текста."
345
  lines = lumen_formatting._split_inline_numbered(text).split("\n")
346
- assert lines[0] == "Причины такие:"
347
- assert [l[:2] for l in lines[1:]] == ["1.", "2.", "3."]
 
 
 
 
348
 
349
 
350
  def test_split_inline_numbered_ignores_prose_and_versions():
@@ -443,13 +420,13 @@ def test_md_to_html_full_pipeline_converts_bullet_list_with_bold():
443
  # модели OpenRouter) регулярно их всё равно пишут — раньше "###" уходило в
444
  # Telegram буквально, без единой защитной сетки (в отличие от таблиц/списков).
445
 
446
- def test_normalize_headers_converts_h3_to_bold():
447
- assert lumen_formatting._normalize_headers("### Как она выводится?") == "**Как она выводится?**"
448
-
449
-
450
- def test_normalize_headers_converts_h1_and_h2():
451
- assert lumen_formatting._normalize_headers("# Заголовок") == "**Заголовок**"
452
- assert lumen_formatting._normalize_headers("## Подзаголовок") == "**Подзаголовок**"
453
 
454
 
455
  def test_normalize_headers_does_not_touch_hash_mid_line():
@@ -492,12 +469,12 @@ def test_md_to_html_header_hash_inside_code_block_untouched():
492
 
493
  # ─────────────────── spoiler-тег: защитная сетка (тот же принцип, что <u>) ───────────────────
494
 
495
- def test_md_to_html_strips_literal_spoiler_tag():
496
- assert lumen_formatting._md_to_html("<tg-spoiler>секрет</tg-spoiler>") == "секрет"
497
-
498
-
499
- def test_md_to_html_strips_literal_span_spoiler_tag():
500
- assert lumen_formatting._md_to_html('<span class="tg-spoiler">секрет</span>') == "секрет"
501
 
502
 
503
  # ─────────────────── подсветка синтаксиса: язык из ```fence сохраняется ───────────────────
@@ -509,13 +486,12 @@ def test_md_to_html_code_block_without_language_unchanged():
509
 
510
  # ─────────────────── markdown-цитаты "> " → <blockquote> ───────────────────
511
 
512
- def test_md_to_html_converts_single_line_blockquote():
513
- assert lumen_formatting._md_to_html("> цитата") == "<blockquote>цитата</blockquote>"
514
-
515
-
516
- def test_md_to_html_converts_multiline_blockquote_as_one_block():
517
- result = lumen_formatting._md_to_html("> первая строка\n> вторая строка")
518
- assert result == "<blockquote>первая строка\nвторая строка</blockquote>"
519
 
520
 
521
  def test_md_to_html_blockquote_markdown_inside_still_converts():
@@ -587,16 +563,29 @@ def test_rich_non_table_pipes_left_alone():
587
  assert lumen_formatting._md_to_rich_html("a | b") == "a | b"
588
 
589
 
590
- def test_rich_headings_become_h_tags():
591
- assert lumen_formatting._md_to_rich_html("## Заголовок") == "<h3>Заголовок</h3>"
592
- assert lumen_formatting._md_to_rich_html("### Подзаголовок") == "<h4>Подзаголовок</h4>"
593
-
594
-
595
- def test_rich_math_preserved_as_tg_math_tags():
 
 
 
 
 
 
 
 
 
 
 
 
 
 
596
  # В отличие от _md_to_html (юникод-замена), рич-путь отдаёт LaTeX сырым —
597
  # рендерит сервер Telegram.
598
- assert lumen_formatting._md_to_rich_html("корень $x^2$ тут") == "корень <tg-math>x^2</tg-math> тут"
599
- assert lumen_formatting._md_to_rich_html("формула $$E=mc^2$$ конец") == "формула <tg-math-block>E=mc^2</tg-math-block> конец"
600
 
601
 
602
  def test_rich_keeps_bold_links_code_quotes_and_escape():
@@ -612,20 +601,6 @@ def test_rich_math_inside_code_stays_code():
612
  assert lumen_formatting._md_to_rich_html("`$x$`") == "<code>$x$</code>"
613
 
614
 
615
- def test_rich_bracket_math_forms():
616
- # Скобочные формы LaTeX (реальный кейс nemotron): \[...\] — блочная,
617
- # \(...\) — инлайн. Извлекаются раньше $ во избежание конфликтов.
618
- assert lumen_formatting._md_to_rich_html("смотри \\[S = \\pi r^{2}\\] конец") == "смотри <tg-math-block>S = \\pi r^{2}</tg-math-block> конец"
619
- assert lumen_formatting._md_to_rich_html("значение \\(x\\) тут") == "значение <tg-math>x</tg-math> тут"
620
-
621
-
622
- def test_rich_headings_tolerate_indent_and_deep_levels():
623
- # Тот же допуск, что у легаси-нормализации: ведущие пробелы и 1–6 решёток.
624
- assert lumen_formatting._md_to_rich_html(" ## Заголовок") == "<h3>Заголовок</h3>"
625
- assert lumen_formatting._md_to_rich_html("# Топ") == "<h2>Топ</h2>"
626
- assert lumen_formatting._md_to_rich_html("#### Глубокий") == "<h4>Глубокий</h4>"
627
-
628
-
629
  def test_br_tag_becomes_line_break_in_both_paths():
630
  # Прод-кейс 17.09.2026: модель пишет "<br>" внутри ячеек таблиц — без
631
  # обработки долетает до escape и светится буквально ("...сгор.<br>Pro Max").
 
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():
 
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 сохраняется ───────────────────
 
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").