"""日报结构化入库:模型 + _build_report_data 组装(纯逻辑,不连 DB)。""" from __future__ import annotations import json from collections import Counter from datetime import date, datetime import pytest from pydantic import ValidationError from report_db.models import EventRow, ReportData from scheduler.reporter import _build_report_data # --------------------------------------------------------------------------- # # 模型默认值 # --------------------------------------------------------------------------- # class TestEventRow: def test_minimal(self) -> None: ev = EventRow(section="intl", rank=1, title="标题") assert ev.importance is None assert ev.sentiment is None def test_full(self) -> None: ev = EventRow( section="intl", rank=2, importance=4, event_type="地缘政治", title="t", summary="s", sentiment="negative", source="InvestingLive", url="https://x.com/1", ) assert ev.sentiment == "negative" def test_missing_title_raises(self) -> None: with pytest.raises(ValidationError): EventRow(section="intl", rank=1) # type: ignore[call-arg] class TestReportData: def test_defaults(self) -> None: r = ReportData( report_date=date(2026, 8, 4), report_type="intl", generated_at=datetime(2026, 8, 4, 8, 0), ) assert r.file_name == "" assert r.stats == {} assert r.events == [] def test_with_events(self) -> None: r = ReportData( report_date=date(2026, 8, 4), report_type="intl", generated_at=datetime(2026, 8, 4, 8, 0), ai_summary="摘要", events=[EventRow(section="intl", rank=1, title="t")], ) assert len(r.events) == 1 # --------------------------------------------------------------------------- # # _build_report_data(intl 结构) # --------------------------------------------------------------------------- # def _fake_high_event(title_zh: str, importance: int, *, event_type: str = "其他", sentiment: str = "neutral", summary_zh: str = "摘要", source_id: str = "investinglive", url: str = "https://investinglive.com/news/1") -> dict: """构造 _dedup_events 之后的事件结构(含 article 属性)。""" return { "importance": importance, "sentiment": sentiment, "summary_zh": summary_zh, "event_type": event_type, "stock_codes": [], "article": { "title": f"EN {title_zh}", "title_zh": title_zh, "url": url, "source_id": source_id, }, } class TestBuildReportData: def test_sections_and_ranks(self) -> None: now = datetime(2026, 8, 4, 8, 0, 0) high = [ _fake_high_event("新闻A", 5), _fake_high_event("新闻B", 4), _fake_high_event("新闻C", 3), ] r = _build_report_data( now, {"raw_total": 100, "proc": 90}, high, Counter(), Counter(), Counter(), Counter(), "AI摘要", ) assert r.report_date == date(2026, 8, 4) assert r.report_type == "intl" assert r.file_name == "" assert r.ai_summary == "AI摘要" assert [(e.section, e.rank) for e in r.events] == [ ("intl", 1), ("intl", 2), ("intl", 3), ] def test_field_mapping(self) -> None: now = datetime(2026, 8, 4, 8, 0, 0) high = [_fake_high_event("英伟达财报超预期", 5, event_type="公司财报", sentiment="positive", summary_zh="营收超预期", source_id="investinglive", url="https://investinglive.com/news/42")] r = _build_report_data(now, {}, high, Counter(), Counter(), Counter(), Counter(), "") ev = r.events[0] assert ev.title == "英伟达财报超预期" # title_zh 优先 assert ev.importance == 5 assert ev.event_type == "公司财报" assert ev.summary == "营收超预期" assert ev.sentiment == "positive" assert ev.url == "https://investinglive.com/news/42" assert ev.source == "InvestingLive" # 域名 → 源展示名 def test_source_fallback_without_url(self) -> None: now = datetime(2026, 8, 4, 8, 0, 0) ev = _fake_high_event("无URL事件", 4, url="") r = _build_report_data(now, {}, [ev], Counter(), Counter(), Counter(), Counter(), "") assert r.events[0].url is None # url 为空时回退 source_id → sources.yaml 展示名 assert r.events[0].source == "InvestingLive" def test_stats_snapshot(self) -> None: now = datetime(2026, 8, 4, 8, 0, 0) high = [ _fake_high_event("A", 5, event_type="宏观", sentiment="positive"), _fake_high_event("B", 4, event_type="宏观", sentiment="negative"), ] r = _build_report_data( now, {"raw_total": 120, "proc": 100, "deduped": 90, "emb_count": 80, "qdrant_count": 75}, high, Counter({"positive": 1, "negative": 1}), Counter({5: 1, 4: 1}), Counter({"宏观": 2}), Counter({"investinglive": 2}), "摘要", ) # stats 可 JSON 序列化(入库时 json.dumps) json.dumps(r.stats, ensure_ascii=False) assert r.stats["pipeline"]["raw_total"] == 120 assert r.stats["sentiment"] == {"positive": 1, "negative": 1} assert r.stats["importance"] == [ {"importance": 4, "count": 1}, {"importance": 5, "count": 1}, ] assert r.stats["event_types"] == [{"event_type": "宏观", "count": 2}] assert r.stats["source_dist"] == [{"source": "InvestingLive", "count": 2}] def test_title_truncated(self) -> None: now = datetime(2026, 8, 4, 8, 0, 0) ev = _fake_high_event("长" * 600, 4) r = _build_report_data(now, {}, [ev], Counter(), Counter(), Counter(), Counter(), "") assert len(r.events[0].title) == 512 def test_empty_events(self) -> None: now = datetime(2026, 8, 4, 8, 0, 0) r = _build_report_data(now, {}, [], Counter(), Counter(), Counter(), Counter(), "") assert r.events == [] assert r.ai_summary is None def test_question_mark_sentiment_normalized(self) -> None: now = datetime(2026, 8, 4, 8, 0, 0) ev = _fake_high_event("未分类情绪", 4, sentiment="?") r = _build_report_data(now, {}, [ev], Counter(), Counter(), Counter(), Counter(), "") # "?" 不写入 DB,留 None assert r.events[0].sentiment is None