Files
US2KR/tests/_orchestration_stubs.py
T
jsahnandClaude Fable 5 f5fa7ec70f Add a Monday-morning weekly recap briefing
No US session closes into Monday KST, so Mondays (default 07:00, cron
in the daemon with restart catch-up) now publish a weekly review built
entirely from stored data: the quotes table collapsed into week-over-
week changes and the week's daily briefing headlines as the narrative.
The recap is a BriefingResult kind="weekly" reusing MarketPayload, so
the store, verification pass, all delivery channels (Blogger title
"미국 주간 리뷰 <monday>") and the dashboard (주간 리뷰 badge) work
unchanged. Manual runs via `run-once --weekly [--date <monday>]`.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-12 23:52:31 +09:00

210 lines
7.6 KiB
Python

"""Install placeholder modules for collaborators that other agents have not written yet.
Only modules whose *file does not exist* are stubbed, so once the real modules land these helpers
are no-ops and the orchestration tests exercise the real imports (still monkeypatched per test).
Test modules import this helper first (isort puts it before the first-party ``us2kr`` block).
"""
from __future__ import annotations
import importlib
import importlib.util
import sys
import types
from datetime import date, datetime, timedelta
from zoneinfo import ZoneInfo
_STUBS: dict[str, list[str]] = {
"us2kr.market_time": ["MarketClock"],
"us2kr.collectors.prices": ["fetch_quotes"],
"us2kr.collectors.news": ["fetch_news"],
"us2kr.collectors.events": ["fetch_events", "fetch_earnings"],
"us2kr.store": ["Store"],
"us2kr.summarize": ["build_provider", "summarize_payload"],
"us2kr.render": ["render_markdown", "render_telegram_chunks"],
"us2kr.deliver": ["build_deliverers"],
}
def _placeholder(name: str):
def _stub(*_args, **_kwargs):
raise RuntimeError(f"{name} is a test stub - monkeypatch it in the test")
_stub.__name__ = name
return _stub
def install_missing_stubs() -> list[str]:
"""Return the list of module names that were stubbed (empty when everything exists)."""
stubbed: list[str] = []
for modname, names in _STUBS.items():
if modname in sys.modules and not isinstance(sys.modules[modname], _StubModule):
module = sys.modules[modname]
elif importlib.util.find_spec(modname) is None:
module = _StubModule(modname)
sys.modules[modname] = module
stubbed.append(modname)
else:
module = importlib.import_module(modname)
for name in names:
if not hasattr(module, name): # package exists but the attribute is not written yet
setattr(module, name, _placeholder(name))
return stubbed
class _StubModule(types.ModuleType):
pass
# Importing this helper (before any ``us2kr.pipeline/daemon/cli`` import) installs the stubs.
STUBBED_MODULES = install_missing_stubs()
# --------------------------------------------------------------------------- shared fakes
ET = ZoneInfo("America/New_York")
KST = ZoneInfo("Asia/Seoul")
class FakeClock:
"""Deterministic stand-in for us2kr.market_time.MarketClock (weekdays are sessions)."""
def __init__(
self,
*,
run_offset_minutes: int = 75,
krx_open: bool = True,
latest: date = date(2026, 8, 25),
catchup: date | None = None,
next_run: tuple[date, datetime] | None = None,
) -> None:
self.run_offset_minutes = run_offset_minutes
self.krx_open = krx_open
self.latest = latest
self.catchup = catchup
self.next_run = next_run
self.calls: list[tuple[str, object]] = []
def is_us_session(self, d: date) -> bool:
return d.weekday() < 5
def is_krx_session(self, d: date) -> bool:
return self.krx_open and d.weekday() < 5
def us_session_close(self, session: date) -> datetime:
if not self.is_us_session(session):
raise ValueError(f"{session} is not a US session")
return datetime(session.year, session.month, session.day, 16, 0, tzinfo=ET)
def previous_us_session(self, before: date) -> date:
d = before - timedelta(days=1)
while not self.is_us_session(d):
d -= timedelta(days=1)
return d
def next_us_session(self, after: date) -> date:
d = after + timedelta(days=1)
while not self.is_us_session(d):
d += timedelta(days=1)
return d
def next_krx_session(self, on_or_after: date) -> date:
d = on_or_after
for _ in range(30):
if self.is_krx_session(d):
return d
d += timedelta(days=1)
return on_or_after + timedelta(days=30) # krx_open=False: KRX "never" reopens -> briefing superseded
def latest_completed_session(self, now: datetime) -> date:
self.calls.append(("latest_completed_session", now))
return self.latest
def briefing_time_for(self, session: date) -> datetime:
return (self.us_session_close(session) + timedelta(minutes=self.run_offset_minutes)).astimezone(KST)
def krx_date_for(self, session: date) -> date:
return self.briefing_time_for(session).date()
def next_briefing(self, now: datetime) -> tuple[date, datetime]:
self.calls.append(("next_briefing", now))
if self.next_run is not None:
return self.next_run
d = now.astimezone(ET).date()
while not self.is_us_session(d) or self.briefing_time_for(d) <= now:
d += timedelta(days=1)
return d, self.briefing_time_for(d)
def catchup_candidate(self, now: datetime, window_hours: int) -> date | None:
self.calls.append(("catchup_candidate", (now, window_hours)))
return self.catchup
class FakeStore:
"""In-memory stand-in for us2kr.store.Store."""
def __init__(self, *, notes: list[str] | None = None, fail_history: bool = False) -> None:
self.quotes: dict[date, list] = {}
self.reports: dict[tuple[date, str], int] = {}
self.saved_results: list = []
self.sent: list[tuple[int, str, str]] = []
self.raw: dict[tuple[date, str], str] = {}
self.notes = notes or []
self.fail_history = fail_history
self.closed = False
self._next_id = 1
# weekly-recap sources: dict rows mimicking sqlite3.Row's __getitem__ access
self.quote_rows: list[dict] = []
self.report_rows: list[dict] = []
def save_quotes(self, session_date: date, quotes: list) -> None:
self.quotes[session_date] = list(quotes)
def history_notes(self, session_date: date, quotes: list) -> list[str]:
if self.fail_history:
raise RuntimeError("db locked")
return list(self.notes)
def has_report(self, krx_date: date, kind: str = "briefing") -> bool:
return (krx_date, kind) in self.reports
def save_report(self, result, kind: str = "briefing") -> int:
report_id = self._next_id
self._next_id += 1
self.reports[(result.payload.krx_date, kind)] = report_id
self.saved_results.append(result)
return report_id
def mark_sent(self, report_id: int, channel: str, receipt: str) -> None:
self.sent.append((report_id, channel, receipt))
def save_raw(self, session_date: date, name: str, payload_json: str) -> None:
self.raw[(session_date, name)] = payload_json
def load_quotes_between(self, start: date, end: date) -> list[dict]:
rows = [r for r in self.quote_rows if start.isoformat() <= r["session_date"] <= end.isoformat()]
return sorted(rows, key=lambda r: (r["symbol"], r["session_date"]))
def reports_between(self, start: date, end: date, kind: str = "briefing") -> list[dict]:
rows = [r for r in self.report_rows if start.isoformat() <= r["krx_date"] <= end.isoformat()]
return sorted(rows, key=lambda r: r["krx_date"])
def close(self) -> None:
self.closed = True
class FakeDeliverer:
def __init__(self, name: str = "fake", *, fail: bool = False) -> None:
self.name = name
self.fail = fail
self.delivered: list = []
self.errors: list[tuple[str, str]] = []
def deliver(self, result) -> str:
if self.fail:
raise RuntimeError(f"{self.name} is down")
self.delivered.append(result)
return f"{self.name}-receipt-{len(self.delivered)}"
def notify_error(self, subject: str, detail: str) -> None:
self.errors.append((subject, detail))