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>") == "&lt;script&gt;alert(1)&lt;/script&gt;"


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>&lt;tag&gt;</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 "&lt;b/&gt;" 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 &lt; 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 &gt; 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 '&quot;' 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> &lt;x&gt;</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>&lt;x&gt;</code>"
    assert lumen_formatting._md_to_rich_html("5 < 10") == "5 &lt; 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