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>
365 lines
15 KiB
Python
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&P 500은 9999% 상승</b>" in joined
|
|
# links converted + escaped
|
|
assert '<a href="https://example.com/a?x=1&y=2">Fed\'s Powell (signals) cuts & more</a>' in joined
|
|
assert '<a href="https://example.com/b">Chips <rally></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&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("<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("&") == 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&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
|