"""Tests for us2kr.render (Markdown + Telegram HTML chunking). No network.""" from __future__ import annotations import re from datetime import date, datetime from zoneinfo import ZoneInfo import pytest from us2kr.models import EarningsItem, EconEvent, MarketPayload, NewsItem, Quote, Report from us2kr.render import render_markdown, render_telegram_chunks from us2kr.symbols import GROUP_TITLES_KO, display_name, group_of ET = ZoneInfo("America/New_York") KST = ZoneInfo("Asia/Seoul") UTC = ZoneInfo("UTC") def _q(symbol: str, close: float, prev: float, **kw) -> Quote: return Quote( symbol=symbol, name=kw.pop("name", display_name(symbol)), group=kw.pop("group", group_of(symbol) or "other"), close=close, prev_close=prev, change_pct=(close / prev - 1) * 100, change_abs=close - prev, **kw, ) def _payload(**overrides) -> MarketPayload: base: dict = { "session_date": date(2026, 8, 25), "us_close_time": datetime(2026, 8, 25, 16, 0, tzinfo=ET), "generated_at": datetime(2026, 8, 26, 6, 15, tzinfo=KST), "krx_date": date(2026, 8, 26), "krx_open_today": True, "quotes": [ _q("^GSPC", 6432.1, 6398.9), _q("^VIX", 15.2, 16.0), _q("^TNX", 4.256, 4.221), _q("KRW=X", 1395.2, 1390.0), _q("NVDA", 182.5, 180.0, after_hours_price=185.0, after_hours_change_pct=1.37), _q("TSM", 240.0, 238.0), ], "news": [ NewsItem( title="Fed's Powell [signals] cuts & more", summary="", link="https://example.com/a?x=1&y=2", source="CNBC", published=datetime(2026, 8, 25, 21, 30, tzinfo=UTC), # 06:30 KST ), NewsItem( title="Chips ", link="https://example.com/b", source="MarketWatch", published=datetime(2026, 8, 25, 20, 0, tzinfo=UTC), ), ], "events": [ EconEvent( name="CPI (YoY)", time=datetime(2026, 8, 25, 8, 30, tzinfo=ET), actual="2.9%", forecast="3.0%", previous="3.1%", importance="high", kind="released", ), EconEvent(name="FOMC 의사록", time=datetime(2026, 8, 26, 14, 0, tzinfo=ET), kind="upcoming"), ], "earnings": [ EarningsItem( symbol="NVDA", name="엔비디아", report_date=date(2026, 8, 25), timing="amc", eps_actual=1.05, eps_estimate=1.01, revenue_actual=46_700_000_000, revenue_estimate=46_000_000_000, ) ], "history_notes": ["S&P 500 3일 연속 상승"], "data_gaps": ["events: TimeoutError: ..."], } base.update(overrides) return MarketPayload(**base) def _report() -> Report: return Report( headline="반도체 주도 반등, S&P 500은 9999% 상승", market_overview="지수는 상승 마감했다.\n반도체가 주도했다.", key_drivers=["엔비디아 실적 기대", "금리 하락"], rates_fx_commodities="10년물 금리 하락, 달러 약세.", korea_implications=["SOX 강세 → 국내 반도체 우호적", "원화 강세"], watchlist_today=["삼성전자 실적"], risk_flags=["FOMC 의사록"], data_gaps=["events"], ) # ------------------------------------------------------------------ render_markdown def test_markdown_header_and_degraded_mode(): md = render_markdown(_payload(krx_open_today=False), None, None) assert md.startswith("# 🇺🇸→🇰🇷 미국 마감 브리핑 — 2026-08-26 (미 2026-08-25 세션)\n") assert "미국 마감: 2026-08-26 05:00 KST · 생성: 2026-08-26 06:15 KST" in md assert "⚠️ 오늘 한국 휴장" in md assert "🧩 축소 모드(LLM 요약 없음)" in md assert "## 총평" not in md and "## 시장 개요" not in md assert "## 📊 주요 지수" in md # tables still present assert "요약 모델" not in md # the model is an implementation detail, never displayed def test_markdown_with_report_sections_and_numbers_from_payload(): md = render_markdown(_payload(), _report(), "MiniMax-M3") assert "🧩 축소 모드" not in md assert "⚠️ 오늘 한국 휴장" not in md assert "## 총평\n**반도체 주도 반등, S&P 500은 9999% 상승**" in md assert "## 시장 개요\n지수는 상승 마감했다. 반도체가 주도했다." in md # newlines collapsed assert "## 주요 요인\n- 엔비디아 실적 기대\n- 금리 하락" in md assert "## 금리·환율·원자재\n10년물 금리 하락, 달러 약세." in md assert "## 한국 장 시사점\n- SOX 강세 → 국내 반도체 우호적" in md assert "## 오늘 볼 것\n- 삼성전자 실적" in md assert "## 리스크\n- FOMC 의사록" in md # numeric table values come from the payload assert "| S&P 500 | 6,432.10 | +0.52% |" in md assert "| VIX | 15.20 | -5.00% |" in md assert "요약 모델" not in md def test_markdown_group_order_and_sections(): md = render_markdown(_payload(), None, None) positions = [md.index(f"## 📊 {GROUP_TITLES_KO[g]}") for g in ("indices", "rates", "fx", "semis")] assert positions == sorted(positions) assert "## 📊 야간 선물" not in md # group with no quotes is omitted for title in ("## 히스토리 노트", "## 주요 뉴스", "## 경제지표·실적", "## 수집 누락"): assert title in md assert md.index("## 📊 빅테크") < md.index("## 히스토리 노트") if "## 📊 빅테크" in md else True assert "- S&P 500 3일 연속 상승" in md assert "- events: TimeoutError: ..." in md def test_markdown_rates_bp_formatting(): md = render_markdown(_payload(), None, None) assert "| 미 10년물 | 4.256 | +3.5bp |" in md def test_markdown_after_hours_column_only_when_present(): md = render_markdown(_payload(), None, None) semis = md.split("## 📊 반도체")[1].split("## ")[0] assert "| 종목 | 종가 | 변동 | 애프터 |" in semis assert "| 엔비디아 | 182.50 | +1.39% | 185.00 (+1.37%) |" in semis assert "| TSMC | 240.00 | +0.84% | — |" in semis indices = md.split("## 📊 주요 지수")[1].split("## ")[0] assert "애프터" not in indices def test_markdown_missing_values_and_pipe_escape(): q = Quote(symbol="XYZ", name="A|B Corp", group="indices") md = render_markdown(_payload(quotes=[q]), None, None) assert "| A\\|B Corp | — | — |" in md def test_markdown_news_events_earnings(): md = render_markdown(_payload(), None, None) assert "- [Fed's Powell (signals) cuts & more](https://example.com/a?x=1&y=2) — CNBC, 06:30 KST" in md assert "- [Chips ](https://example.com/b) — MarketWatch, 05:00 KST" in md assert "★ CPI (YoY): 실제 2.9% / 예상 3.0% / 이전 3.1%" in md assert "[발표 08/25 21:30 KST]" in md # 08:30 ET -> 21:30 KST assert "- [예정 08/27 03:00 KST] FOMC 의사록" in md assert ( "- [실적·장마감후] 엔비디아(NVDA) 2026-08-25: EPS 1.05 (예상 1.01) · 매출 46.70B (예상 46.00B)" in md ) def test_markdown_news_capped_at_12(): news = [ NewsItem( title=f"t{i}", link=f"https://e.com/{i}", source="S", published=datetime(2026, 8, 25, tzinfo=UTC) ) for i in range(20) ] md = render_markdown(_payload(news=news), None, None) assert len(re.findall(r"^- \[t\d+\]", md, flags=re.MULTILINE)) == 12 def test_markdown_optional_sections_omitted_when_empty(): md = render_markdown( _payload(news=[], events=[], earnings=[], history_notes=[], data_gaps=[]), None, None ) for title in ("## 히스토리 노트", "## 주요 뉴스", "## 경제지표·실적", "## 수집 누락"): assert title not in md # ------------------------------------------------------------------ render_telegram_chunks def _assert_balanced(chunk: str) -> None: for tag in ("pre", "b", "a", "i"): opens = len(re.findall(rf"<{tag}(?:\s[^>]*)?>", chunk)) closes = chunk.count(f"") assert opens == closes, (tag, chunk) # no stray unescaped angle brackets / ampersands outside tags stripped = re.sub(r"<(/?)(pre|b|a|i)(\s[^>]*)?>", "", chunk) assert "<" not in stripped and ">" not in stripped, stripped assert re.search(r"&(?!amp;|lt;|gt;|quot;)", stripped) is None, stripped def test_telegram_conversion_basics(): md = render_markdown(_payload(), _report(), "MiniMax-M3") chunks = render_telegram_chunks(md, limit=4000) assert chunks joined = "\n".join(chunks) for c in chunks: assert len(c) <= 4000 _assert_balanced(c) # headers and bold assert "🇺🇸→🇰🇷 미국 마감 브리핑 — 2026-08-26 (미 2026-08-25 세션)" in joined assert "총평\n반도체 주도 반등, S&P 500은 9999% 상승" in joined # links converted + escaped assert 'Fed\'s Powell (signals) cuts & more' in joined assert 'Chips <rally>' in joined # bullets assert "• 엔비디아 실적 기대" in joined # tables -> pre with aligned monospace and no markdown pipes assert "
" in joined and "
" in joined pre_blocks = re.findall(r"
\n(.*?)\n
", joined, flags=re.DOTALL) assert pre_blocks assert any("6,432.10" in b and "+0.52%" in b for b in pre_blocks) assert not any("|" in b for b in pre_blocks) # horizontal rule dropped, no raw markdown syntax left assert "\n---" not in joined assert "**" not in joined and "](" not in joined and "\n## " not in joined def test_telegram_pre_alignment_and_escape(): md = "## T\n| 종목 | 종가 |\n|---|---|\n| A&B | 1.00 |\n| LongName | 12,345.00 |\n" chunks = render_telegram_chunks(md) assert len(chunks) == 1 body = chunks[0] assert body.startswith("T\n
\n")
    lines = body.split("\n")[2:-1]
    assert lines[0].startswith("종목")
    assert "A&B" in lines[1] and "LongName <x>" in lines[2]
    # numeric column right-aligned: values end at same position (after unescaping)
    unesc = [ln.replace("&", "&").replace("<", "<").replace(">", ">") for ln in lines]
    ends = {len(ln) for ln in unesc[1:]}
    assert len(ends) == 1


def test_telegram_greedy_packing_by_section():
    md = "\n".join(
        [
            "# Title",
            "meta",
            "",
            "## A",
            "a" * 100,
            "",
            "## B",
            "b" * 100,
            "",
            "## C",
            "c" * 100,
            "",
        ]
    )
    chunks = render_telegram_chunks(md, limit=260)
    assert all(len(c) <= 260 for c in chunks)
    assert len(chunks) == 2
    assert chunks[0].startswith("Title\nmeta\n\nA\n")
    assert "B" in chunks[0] and "C" in chunks[1]
    assert chunks[1].startswith("C")


def test_telegram_split_inside_pre_keeps_balance():
    rows = "\n".join(f"| Name{i:02d} | {i * 1000:,.2f} | +{i}.00% |" for i in range(60))
    md = f"## Big\n| 종목 | 종가 | 변동 |\n|---|---|---|\n{rows}\n"
    limit = 300
    chunks = render_telegram_chunks(md, limit=limit)
    assert len(chunks) > 1
    for c in chunks:
        assert len(c) <= limit
        _assert_balanced(c)
        assert c.count("
") == 1  # every chunk of the table reopens the block
    assert chunks[0].startswith("Big\n
\n")
    assert chunks[1].startswith("
\n")
    # nothing lost
    all_text = "\n".join(chunks)
    for i in range(60):
        assert f"Name{i:02d}" in all_text


def test_telegram_split_mixed_text_and_pre_in_one_section():
    lines = ["## S"] + [f"- item {i} " + "x" * 40 for i in range(30)]
    lines += ["| a | b |", "|---|---|"] + [f"| r{i} | {i} |" for i in range(30)]
    lines += [f"tail {i} " + "y" * 30 for i in range(10)]
    chunks = render_telegram_chunks("\n".join(lines), limit=400)
    assert len(chunks) > 3
    for c in chunks:
        assert len(c) <= 400
        _assert_balanced(c)
        assert not c.startswith("\n") and not c.endswith("\n")
    joined = "\n".join(chunks)
    assert joined.count("• item") == 30 and joined.count("tail ") == 10
    assert all(f"r{i} " in joined or f"r{i}\n" in joined or f"r{i}  " in joined for i in range(30))


def test_telegram_limit_accounts_for_escaping():
    md = "## E\n" + "&" * 100  # -> 500 chars once escaped
    chunks = render_telegram_chunks(md, limit=120)
    assert all(len(c) <= 120 for c in chunks)
    assert "".join(chunks).count("&") == 100


def test_telegram_empty_input():
    assert render_telegram_chunks("") == []
    assert render_telegram_chunks("\n\n---\n") == []


@pytest.mark.parametrize("limit", [200, 1000, 4000])
def test_telegram_full_briefing_roundtrip(limit: int):
    md = render_markdown(_payload(), _report(), "MiniMax-M3")
    chunks = render_telegram_chunks(md, limit=limit)
    for c in chunks:
        assert 0 < len(c) <= limit
        _assert_balanced(c)
    joined = "\n".join(chunks)
    assert "미 10년물" in joined and "+3.5bp" in joined


def test_news_link_with_parentheses_and_space_survives_telegram_conversion():
    from us2kr.render import _inline, _link_url, _news_lines

    published = datetime(2026, 8, 25, tzinfo=UTC)
    items = [
        NewsItem(title="p", link="https://ex.com/a_(b)?x=1&y=2", source="S", published=published),
        NewsItem(title="s", link=" https://ex.com/a b/c ", source="S", published=published),
        NewsItem(
            title="e",
            link="https://x.com/%E2%82%AC/caf%C3%A9?q=a%20b#frag",
            source="S",
            published=published,
        ),
    ]
    html = [_inline(line) for line in _news_lines(items)]
    assert html[0].startswith('- p — S, ')
    assert ")" not in html[0].split("", 1)[1]
    assert 's' in html[1]
    assert "[s](" not in html[1]
    # already-encoded URLs are left untouched (no double encoding)
    assert _link_url("https://x.com/%E2%82%AC/caf%C3%A9?q=a%20b#frag") == "https://x.com/%E2%82%AC/caf%C3%A9?q=a%20b#frag"
    assert 'e' in html[2]
    # end-to-end through the chunker: exactly one anchor per item, no stray raw markdown
    md = render_markdown(_payload(news=items), None, None)
    joined = "\n".join(render_telegram_chunks(md))
    assert joined.count("