Files
US2KR/tests/test_render.py
T
jsahnandClaude Fable 5 ab16eaf5a7 Stop displaying the summary model name
Drop the "요약 모델" footer from rendered briefings and the model badge
from the dashboard (degraded briefings keep a "요약 없음" chip). The
model is still recorded in the DB/result JSON for diagnostics.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-01 01:04:47 +09:00

365 lines
15 KiB
Python

"""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 <rally>",
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 <rally>](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"</{tag}>")
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 "<b>🇺🇸→🇰🇷 미국 마감 브리핑 — 2026-08-26 (미 2026-08-25 세션)</b>" in joined
assert "<b>총평</b>\n<b>반도체 주도 반등, S&amp;P 500은 9999% 상승</b>" in joined
# links converted + escaped
assert '<a href="https://example.com/a?x=1&amp;y=2">Fed\'s Powell (signals) cuts &amp; more</a>' in joined
assert '<a href="https://example.com/b">Chips &lt;rally&gt;</a>' in joined
# bullets
assert "• 엔비디아 실적 기대" in joined
# tables -> pre with aligned monospace and no markdown pipes
assert "<pre>" in joined and "</pre>" in joined
pre_blocks = re.findall(r"<pre>\n(.*?)\n</pre>", 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 <x> | 12,345.00 |\n"
chunks = render_telegram_chunks(md)
assert len(chunks) == 1
body = chunks[0]
assert body.startswith("<b>T</b>\n<pre>\n")
lines = body.split("\n")[2:-1]
assert lines[0].startswith("종목")
assert "A&amp;B" in lines[1] and "LongName &lt;x&gt;" in lines[2]
# numeric column right-aligned: values end at same position (after unescaping)
unesc = [ln.replace("&amp;", "&").replace("&lt;", "<").replace("&gt;", ">") 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("<b>Title</b>\nmeta\n\n<b>A</b>\n")
assert "<b>B</b>" in chunks[0] and "<b>C</b>" in chunks[1]
assert chunks[1].startswith("<b>C</b>")
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("<pre>") == 1 # every chunk of the table reopens the block
assert chunks[0].startswith("<b>Big</b>\n<pre>\n")
assert chunks[1].startswith("<pre>\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("&amp;") == 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('- <a href="https://ex.com/a_%28b%29?x=1&amp;y=2">p</a> — S, ')
assert ")" not in html[0].split("</a>", 1)[1]
assert '<a href="https://ex.com/a%20b/c">s</a>' 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 '<a href="https://x.com/%E2%82%AC/caf%C3%A9?q=a%20b#frag">e</a>' 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("<a href=") == 3 and "](" not in joined