mirror of
https://github.com/ZhuLinsen/daily_stock_analysis
synced 2026-09-20 10:53:33 +08:00
2282 lines
96 KiB
Python
2282 lines
96 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""Regression tests for scheduled mode stock selection behavior."""
|
||
|
||
import json
|
||
import logging
|
||
import os
|
||
import socket
|
||
import tempfile
|
||
import unittest
|
||
from datetime import date, datetime, timezone
|
||
from pathlib import Path
|
||
from types import SimpleNamespace
|
||
from unittest.mock import MagicMock, patch
|
||
|
||
from tests.litellm_stub import ensure_litellm_stub
|
||
|
||
ensure_litellm_stub()
|
||
|
||
_ENV_BEFORE_MAIN_IMPORT = dict(os.environ)
|
||
import main
|
||
from src.brokers.futu.portfolio import FutuPortfolioError
|
||
from src.config import Config
|
||
|
||
_MAIN_IMPORT_ENV_ADDITIONS = frozenset(set(os.environ) - set(_ENV_BEFORE_MAIN_IMPORT))
|
||
_MAIN_IMPORT_ENV_OVERRIDES = {
|
||
key: value
|
||
for key, value in _ENV_BEFORE_MAIN_IMPORT.items()
|
||
if os.environ.get(key) != value
|
||
}
|
||
|
||
|
||
def _api_app_stub_modules():
|
||
"""sys.modules entries so ``start_api_server`` can ``from api.app import app``
|
||
without importing the real (heavy) app tree in these isolated unit tests.
|
||
|
||
``start_api_server`` imports the ASGI app object in the calling thread so the
|
||
import stays out of the uvicorn startup probe window; these control-flow tests
|
||
stub it the same way they already stub uvicorn.
|
||
"""
|
||
import types
|
||
|
||
api_pkg = types.ModuleType("api")
|
||
api_app_mod = types.ModuleType("api.app")
|
||
api_app_mod.app = SimpleNamespace()
|
||
api_pkg.app = api_app_mod
|
||
return {"api": api_pkg, "api.app": api_app_mod}
|
||
|
||
|
||
class _DummyConfig(SimpleNamespace):
|
||
def validate(self):
|
||
return []
|
||
|
||
|
||
class MainScheduleModeTestCase(unittest.TestCase):
|
||
def setUp(self) -> None:
|
||
self.temp_dir = tempfile.TemporaryDirectory()
|
||
self.env_path = Path(self.temp_dir.name) / ".env"
|
||
self.env_path.write_text("STOCK_LIST=600519\n", encoding="utf-8")
|
||
self.original_cwd = os.getcwd()
|
||
os.chdir(self.temp_dir.name)
|
||
self.env_patch = patch.dict(os.environ, {"ENV_FILE": str(self.env_path)}, clear=False)
|
||
self.env_patch.start()
|
||
Config.reset_instance()
|
||
root_logger = logging.getLogger()
|
||
self._original_root_handlers = list(root_logger.handlers)
|
||
self._original_root_level = root_logger.level
|
||
|
||
def tearDown(self) -> None:
|
||
root_logger = logging.getLogger()
|
||
current_handlers = list(root_logger.handlers)
|
||
for handler in current_handlers:
|
||
if handler not in self._original_root_handlers:
|
||
root_logger.removeHandler(handler)
|
||
try:
|
||
handler.close()
|
||
except Exception:
|
||
pass
|
||
root_logger.setLevel(self._original_root_level)
|
||
os.chdir(self.original_cwd)
|
||
Config.reset_instance()
|
||
self.env_patch.stop()
|
||
for key in _MAIN_IMPORT_ENV_ADDITIONS:
|
||
os.environ.pop(key, None)
|
||
for key, value in _MAIN_IMPORT_ENV_OVERRIDES.items():
|
||
os.environ[key] = value
|
||
self.temp_dir.cleanup()
|
||
|
||
def _make_args(self, **overrides):
|
||
defaults = {
|
||
"debug": False,
|
||
"stocks": None,
|
||
"portfolio": None,
|
||
"webui": False,
|
||
"webui_only": False,
|
||
"serve": False,
|
||
"serve_only": False,
|
||
"host": None,
|
||
"port": None,
|
||
"backtest": False,
|
||
"market_review": False,
|
||
"schedule": False,
|
||
"no_run_immediately": False,
|
||
"no_notify": False,
|
||
"check_notify": False,
|
||
"no_market_review": False,
|
||
"dry_run": False,
|
||
"workers": 1,
|
||
"force_run": False,
|
||
"single_notify": False,
|
||
"no_context_snapshot": False,
|
||
}
|
||
defaults.update(overrides)
|
||
return SimpleNamespace(**defaults)
|
||
|
||
def _make_config(self, **overrides):
|
||
defaults = {
|
||
"log_dir": self.temp_dir.name,
|
||
"webui_enabled": False,
|
||
"webui_host": "127.0.0.1",
|
||
"webui_port": 8000,
|
||
"dingtalk_stream_enabled": False,
|
||
"feishu_stream_enabled": False,
|
||
"schedule_enabled": False,
|
||
"schedule_time": "18:00",
|
||
"schedule_run_immediately": True,
|
||
"run_immediately": True,
|
||
"agent_event_monitor_enabled": False,
|
||
"agent_event_alert_rules_json": "",
|
||
"agent_event_monitor_interval_minutes": 5,
|
||
"daily_market_context_enabled": True,
|
||
}
|
||
defaults.update(overrides)
|
||
return _DummyConfig(**defaults)
|
||
|
||
def test_daily_market_context_target_date_routes_jp_kr_calendars(self) -> None:
|
||
current_time = datetime(2026, 5, 7, 0, 30, tzinfo=timezone.utc)
|
||
calls = []
|
||
|
||
def resolve_effective_date(market, *, current_time=None):
|
||
calls.append((market, current_time))
|
||
return date(2026, 5, 7)
|
||
|
||
with patch(
|
||
"src.core.trading_calendar.get_effective_trading_date",
|
||
side_effect=resolve_effective_date,
|
||
):
|
||
self.assertEqual(
|
||
main._resolve_daily_market_context_target_date("jp", current_time),
|
||
date(2026, 5, 7),
|
||
)
|
||
self.assertEqual(
|
||
main._resolve_daily_market_context_target_date("kr", current_time),
|
||
date(2026, 5, 7),
|
||
)
|
||
self.assertEqual(
|
||
main._resolve_daily_market_context_target_date("jp,kr", current_time),
|
||
date(2026, 5, 7),
|
||
)
|
||
|
||
self.assertEqual(
|
||
calls,
|
||
[
|
||
("jp", current_time),
|
||
("kr", current_time),
|
||
("jp", current_time),
|
||
],
|
||
)
|
||
|
||
def test_compute_trading_day_filter_supports_comma_list_regions(self) -> None:
|
||
args = self._make_args()
|
||
config = self._make_config(
|
||
trading_day_check_enabled=True,
|
||
market_review_enabled=True,
|
||
market_review_region="jp,kr",
|
||
database_path=str(Path(self.temp_dir.name) / "stock_analysis.db"),
|
||
)
|
||
|
||
stock_codes = ["cn-stock", "jp-stock", "kr-stock", "us-stock", "none-stock"]
|
||
|
||
with patch(
|
||
"src.core.trading_calendar.get_market_for_stock",
|
||
side_effect=lambda code: {"cn-stock": "cn", "jp-stock": "jp", "kr-stock": "kr", "us-stock": "us"}.get(code),
|
||
), patch("src.core.trading_calendar.get_open_markets_today", return_value={"jp", "kr"}):
|
||
filtered_codes, effective_region, should_skip_all = main._compute_trading_day_filter(
|
||
config,
|
||
args,
|
||
stock_codes,
|
||
)
|
||
|
||
self.assertEqual(filtered_codes, ["jp-stock", "kr-stock", "none-stock"])
|
||
self.assertEqual(effective_region, "jp,kr")
|
||
self.assertFalse(should_skip_all)
|
||
|
||
def test_public_webui_bind_warns_when_auth_is_disabled(self) -> None:
|
||
with patch("src.auth.is_auth_enabled", return_value=False), \
|
||
patch("main.logger.warning") as warning_log:
|
||
main._warn_if_public_webui_without_auth("0.0.0.0")
|
||
|
||
warning_log.assert_called_once()
|
||
self.assertIn("WEBUI_HOST=%s", warning_log.call_args.args[0])
|
||
self.assertEqual(warning_log.call_args.args[1], "0.0.0.0")
|
||
|
||
def test_loopback_webui_bind_does_not_warn_when_auth_is_disabled(self) -> None:
|
||
with patch("src.auth.is_auth_enabled", return_value=False), \
|
||
patch("main.logger.warning") as warning_log:
|
||
main._warn_if_public_webui_without_auth("127.0.0.1")
|
||
|
||
warning_log.assert_not_called()
|
||
|
||
def test_web_service_bind_uses_config_when_cli_omits_host_and_port(self) -> None:
|
||
args = self._make_args(host=None, port=None)
|
||
config = self._make_config(webui_host="127.0.0.1", webui_port=18000)
|
||
|
||
host, port = main._resolve_web_service_bind(args, config)
|
||
|
||
self.assertEqual(host, "127.0.0.1")
|
||
self.assertEqual(port, 18000)
|
||
|
||
def test_web_service_bind_keeps_explicit_cli_host_and_port(self) -> None:
|
||
args = self._make_args(host="0.0.0.0", port=8000)
|
||
config = self._make_config(webui_host="127.0.0.1", webui_port=18000)
|
||
|
||
host, port = main._resolve_web_service_bind(args, config)
|
||
|
||
self.assertEqual(host, "0.0.0.0")
|
||
self.assertEqual(port, 8000)
|
||
|
||
def test_serve_only_uses_config_bind_when_cli_omits_host_and_port(self) -> None:
|
||
args = self._make_args(serve_only=True)
|
||
config = self._make_config(webui_enabled=False, webui_host="127.0.0.1", webui_port=18000)
|
||
observed_bind = []
|
||
|
||
def fake_start_api_server(host, port, config):
|
||
observed_bind.append((host, port))
|
||
|
||
with patch.dict(os.environ, {"GITHUB_ACTIONS": "false"}, clear=False), \
|
||
patch("main.parse_arguments", return_value=args), \
|
||
patch("main.get_config", return_value=config), \
|
||
patch("main.prepare_webui_frontend_assets", return_value=True), \
|
||
patch("main.start_api_server", side_effect=fake_start_api_server), \
|
||
patch("main.start_bot_stream_clients"), \
|
||
patch("main.time.sleep", side_effect=KeyboardInterrupt):
|
||
exit_code = main.main()
|
||
|
||
self.assertEqual(exit_code, 0)
|
||
self.assertEqual(observed_bind, [("127.0.0.1", 18000)])
|
||
|
||
def test_serve_only_keeps_explicit_cli_bind_over_config(self) -> None:
|
||
args = self._make_args(serve_only=True, host="0.0.0.0", port=8000)
|
||
config = self._make_config(webui_enabled=False, webui_host="127.0.0.1", webui_port=18000)
|
||
observed_bind = []
|
||
|
||
def fake_start_api_server(host, port, config):
|
||
observed_bind.append((host, port))
|
||
|
||
with patch.dict(os.environ, {"GITHUB_ACTIONS": "false"}, clear=False), \
|
||
patch("main.parse_arguments", return_value=args), \
|
||
patch("main.get_config", return_value=config), \
|
||
patch("main.prepare_webui_frontend_assets", return_value=True), \
|
||
patch("main.start_api_server", side_effect=fake_start_api_server), \
|
||
patch("main.start_bot_stream_clients"), \
|
||
patch("main.time.sleep", side_effect=KeyboardInterrupt):
|
||
exit_code = main.main()
|
||
|
||
self.assertEqual(exit_code, 0)
|
||
self.assertEqual(observed_bind, [("0.0.0.0", 8000)])
|
||
|
||
def test_start_api_server_fails_before_thread_when_port_is_busy(self) -> None:
|
||
config = self._make_config(log_level="INFO")
|
||
|
||
class BusySocket:
|
||
def bind(self, address):
|
||
raise OSError("address already in use")
|
||
|
||
def close(self):
|
||
pass
|
||
|
||
with patch("socket.socket", return_value=BusySocket()) as socket_factory, \
|
||
patch("threading.Thread") as thread_cls:
|
||
with self.assertRaises(RuntimeError) as caught:
|
||
main.start_api_server("127.0.0.1", 8000, config)
|
||
|
||
socket_factory.assert_called_once_with(socket.AF_INET, socket.SOCK_STREAM)
|
||
self.assertIn("127.0.0.1:8000", str(caught.exception))
|
||
thread_cls.assert_not_called()
|
||
|
||
def test_start_api_server_fails_when_uvicorn_background_startup_fails(self) -> None:
|
||
config = self._make_config(log_level="INFO")
|
||
|
||
class _FakeUvicornServer:
|
||
def __init__(self, config):
|
||
self.config = config
|
||
self.started = False
|
||
|
||
def run(self) -> None:
|
||
raise RuntimeError("lifespan bootstrap failed")
|
||
|
||
class _FakeUvicornConfig:
|
||
def __init__(self, *args, **kwargs):
|
||
pass
|
||
|
||
class _FakeUvicornModule:
|
||
Config = _FakeUvicornConfig
|
||
|
||
Server = _FakeUvicornServer
|
||
|
||
class _UnusedSocket:
|
||
def bind(self, address):
|
||
pass
|
||
|
||
def close(self):
|
||
pass
|
||
|
||
with patch("socket.socket", return_value=_UnusedSocket()), \
|
||
patch.dict(
|
||
"sys.modules",
|
||
{"uvicorn": _FakeUvicornModule(), **_api_app_stub_modules()},
|
||
):
|
||
|
||
with self.assertRaises(RuntimeError) as caught:
|
||
main.start_api_server("127.0.0.1", 8000, config)
|
||
|
||
self.assertIn("lifespan bootstrap failed", str(caught.exception))
|
||
|
||
def test_start_api_server_compatible_with_uvicorn_install_signal_handlers_method(self) -> None:
|
||
config = self._make_config(log_level="INFO")
|
||
|
||
class _CompatServer:
|
||
instance = None
|
||
|
||
def __init__(self, config):
|
||
type(self).instance = self
|
||
self.config = config
|
||
self.started = False
|
||
self.install_signal_handlers = self._install_signal_handlers
|
||
|
||
def _install_signal_handlers(self) -> None:
|
||
return None
|
||
|
||
def run(self) -> None:
|
||
self.started = True
|
||
|
||
class _CompatConfig:
|
||
def __init__(self, *args, **kwargs):
|
||
if "install_signal_handlers" in kwargs:
|
||
raise TypeError("install_signal_handlers is unsupported")
|
||
|
||
class _UnusedSocket:
|
||
def bind(self, address):
|
||
pass
|
||
|
||
def close(self):
|
||
pass
|
||
|
||
with patch("socket.socket", return_value=_UnusedSocket()), \
|
||
patch.dict(
|
||
"sys.modules",
|
||
{
|
||
"uvicorn": SimpleNamespace(Config=_CompatConfig, Server=_CompatServer),
|
||
**_api_app_stub_modules(),
|
||
},
|
||
):
|
||
main.start_api_server("127.0.0.1", 8000, config)
|
||
|
||
self.assertIsNotNone(_CompatServer.instance)
|
||
self.assertTrue(callable(_CompatServer.instance.install_signal_handlers))
|
||
self.assertTrue(_CompatServer.instance.started)
|
||
|
||
def test_schedule_mode_ignores_cli_stock_snapshot(self) -> None:
|
||
args = self._make_args(schedule=True, stocks="600519,000001")
|
||
config = self._make_config(schedule_enabled=False)
|
||
scheduled_call = {}
|
||
|
||
def fake_run_with_schedule(
|
||
task,
|
||
schedule_time,
|
||
run_immediately,
|
||
background_tasks=None,
|
||
schedule_time_provider=None,
|
||
):
|
||
scheduled_call["schedule_time"] = schedule_time
|
||
scheduled_call["run_immediately"] = run_immediately
|
||
scheduled_call["background_tasks"] = background_tasks or []
|
||
scheduled_call["resolved_schedule_time"] = (
|
||
schedule_time_provider() if schedule_time_provider is not None else None
|
||
)
|
||
task()
|
||
|
||
with patch("main.parse_arguments", return_value=args), \
|
||
patch("main.get_config", return_value=config), \
|
||
patch("main._reload_runtime_config", return_value=config), \
|
||
patch("main._build_schedule_time_provider", return_value=lambda: "18:00"), \
|
||
patch("main.setup_logging"), \
|
||
patch("main.run_full_analysis") as run_full_analysis, \
|
||
patch("main.logger.warning") as warning_log, \
|
||
patch("src.scheduler.run_with_schedule", side_effect=fake_run_with_schedule):
|
||
exit_code = main.main()
|
||
|
||
self.assertEqual(exit_code, 0)
|
||
self.assertEqual(
|
||
scheduled_call,
|
||
{
|
||
"schedule_time": "18:00",
|
||
"run_immediately": True,
|
||
"background_tasks": [],
|
||
"resolved_schedule_time": "18:00",
|
||
},
|
||
)
|
||
run_full_analysis.assert_called_once_with(config, args, None)
|
||
warning_log.assert_any_call(
|
||
"定时模式下检测到 --stocks 参数;计划执行将忽略启动时股票快照,并在每次运行前重新读取最新的 STOCK_LIST。"
|
||
)
|
||
|
||
def test_standalone_run_resolves_stocks_before_run_full_analysis(self) -> None:
|
||
args = self._make_args(stocks="005930")
|
||
config = self._make_config(run_immediately=True)
|
||
|
||
with patch("main.parse_arguments", return_value=args), \
|
||
patch("main.get_config", return_value=config), \
|
||
patch("main.setup_logging"), \
|
||
patch("main.run_full_analysis") as run_full_analysis:
|
||
exit_code = main.main()
|
||
|
||
self.assertEqual(exit_code, 0)
|
||
run_full_analysis.assert_called_once()
|
||
_, _, stock_codes = run_full_analysis.call_args.args
|
||
self.assertEqual(stock_codes, ["005930.KS"])
|
||
|
||
def test_standalone_run_returns_nonzero_when_startup_analysis_reports_failure(self) -> None:
|
||
args = self._make_args()
|
||
config = self._make_config(run_immediately=True)
|
||
|
||
with patch("main.parse_arguments", return_value=args), \
|
||
patch("main.get_config", return_value=config), \
|
||
patch("main.setup_logging"), \
|
||
patch.object(main, "_LAST_ANALYSIS_FAILURE_REASON", "no_report"), \
|
||
patch("main._run_analysis_with_runtime_scheduler_lock", return_value=False) as run_with_lock:
|
||
exit_code = main.main()
|
||
|
||
self.assertEqual(exit_code, 1)
|
||
run_with_lock.assert_called_once_with(config, args, None)
|
||
|
||
def test_standalone_futu_portfolio_failure_returns_nonzero(self) -> None:
|
||
args = self._make_args(portfolio="futu")
|
||
config = self._make_config(run_immediately=True)
|
||
error = FutuPortfolioError("OpenD unavailable")
|
||
|
||
with (
|
||
patch("main.parse_arguments", return_value=args),
|
||
patch("main.get_config", return_value=config),
|
||
patch("main.setup_logging"),
|
||
patch("main._refresh_stock_index_cache_for_analysis"),
|
||
patch(
|
||
"src.brokers.futu.portfolio.load_futu_stock_codes",
|
||
side_effect=error,
|
||
) as loader,
|
||
):
|
||
exit_code = main.main()
|
||
|
||
self.assertEqual(exit_code, 1)
|
||
loader.assert_called_once_with()
|
||
|
||
def test_standalone_futu_portfolio_success_returns_zero(self) -> None:
|
||
args = self._make_args(portfolio="futu")
|
||
config = self._make_config(run_immediately=True)
|
||
|
||
with (
|
||
patch("main.parse_arguments", return_value=args),
|
||
patch("main.get_config", return_value=config),
|
||
patch("main.setup_logging"),
|
||
patch("main._refresh_stock_index_cache_for_analysis"),
|
||
patch(
|
||
"src.brokers.futu.portfolio.load_futu_stock_codes",
|
||
return_value=["AAPL"],
|
||
) as loader,
|
||
patch(
|
||
"main._compute_trading_day_filter",
|
||
return_value=([], "", True),
|
||
),
|
||
):
|
||
exit_code = main.main()
|
||
|
||
self.assertEqual(exit_code, 0)
|
||
loader.assert_called_once_with()
|
||
|
||
def test_standalone_futu_downstream_failure_keeps_existing_exit_semantics(self) -> None:
|
||
args = self._make_args(portfolio="futu")
|
||
config = self._make_config(run_immediately=True)
|
||
|
||
with (
|
||
patch("main.parse_arguments", return_value=args),
|
||
patch("main.get_config", return_value=config),
|
||
patch("main.setup_logging"),
|
||
patch("main._refresh_stock_index_cache_for_analysis"),
|
||
patch(
|
||
"src.brokers.futu.portfolio.load_futu_stock_codes",
|
||
return_value=["AAPL"],
|
||
) as loader,
|
||
patch(
|
||
"main._compute_trading_day_filter",
|
||
side_effect=RuntimeError("calendar unavailable"),
|
||
),
|
||
):
|
||
exit_code = main.main()
|
||
|
||
self.assertEqual(exit_code, 0)
|
||
loader.assert_called_once_with()
|
||
|
||
def test_schedule_mode_reload_uses_latest_runtime_config(self) -> None:
|
||
args = self._make_args(schedule=True)
|
||
startup_config = self._make_config(schedule_enabled=True, schedule_time="18:00")
|
||
runtime_config = self._make_config(schedule_enabled=True, schedule_time="09:30")
|
||
scheduled_call = {}
|
||
|
||
def fake_run_with_schedule(
|
||
task,
|
||
schedule_time,
|
||
run_immediately,
|
||
background_tasks=None,
|
||
schedule_time_provider=None,
|
||
):
|
||
scheduled_call["schedule_time"] = schedule_time
|
||
scheduled_call["resolved_schedule_time"] = (
|
||
schedule_time_provider() if schedule_time_provider is not None else None
|
||
)
|
||
task()
|
||
|
||
with patch("main.parse_arguments", return_value=args), \
|
||
patch("main.get_config", return_value=startup_config), \
|
||
patch("main._reload_runtime_config", return_value=runtime_config), \
|
||
patch("main._build_schedule_time_provider", return_value=lambda: "09:30"), \
|
||
patch("main.setup_logging"), \
|
||
patch("main.run_full_analysis") as run_full_analysis, \
|
||
patch("src.scheduler.run_with_schedule", side_effect=fake_run_with_schedule):
|
||
exit_code = main.main()
|
||
|
||
self.assertEqual(exit_code, 0)
|
||
self.assertEqual(
|
||
scheduled_call,
|
||
{"schedule_time": "18:00", "resolved_schedule_time": "09:30"},
|
||
)
|
||
run_full_analysis.assert_called_once_with(runtime_config, args, None)
|
||
|
||
def test_schedule_mode_raises_task_failure_when_analysis_returns_false(self) -> None:
|
||
args = self._make_args(schedule=True)
|
||
runtime_config = self._make_config(schedule_enabled=True, schedule_time="09:30")
|
||
scheduled_call = {}
|
||
|
||
def fake_run_with_schedule(
|
||
task,
|
||
schedule_time,
|
||
run_immediately,
|
||
background_tasks=None,
|
||
schedule_time_provider=None,
|
||
):
|
||
scheduled_call["task"] = task
|
||
|
||
with patch("main.parse_arguments", return_value=args), \
|
||
patch("main.get_config", return_value=self._make_config(schedule_enabled=True, schedule_time="18:00")), \
|
||
patch("main._reload_runtime_config", return_value=runtime_config), \
|
||
patch("main._build_schedule_time_provider", return_value=lambda: "09:30"), \
|
||
patch("main.setup_logging"), \
|
||
patch("main.run_full_analysis", return_value=False) as run_full_analysis, \
|
||
patch.object(main, "_LAST_ANALYSIS_FAILURE_REASON", "no_report"), \
|
||
patch("src.scheduler.run_with_schedule", side_effect=fake_run_with_schedule):
|
||
exit_code = main.main()
|
||
with self.assertRaisesRegex(RuntimeError, "scheduled analysis reported failure: no_report"):
|
||
scheduled_call["task"]()
|
||
|
||
self.assertEqual(exit_code, 0)
|
||
run_full_analysis.assert_called_once_with(runtime_config, args, None)
|
||
|
||
def test_schedule_mode_registers_event_monitor_background_task(self) -> None:
|
||
args = self._make_args(schedule=True)
|
||
config = self._make_config(
|
||
schedule_enabled=False,
|
||
agent_event_monitor_enabled=True,
|
||
agent_event_monitor_interval_minutes=7,
|
||
)
|
||
worker = MagicMock()
|
||
worker.run_once.return_value = {"triggered": 2}
|
||
scheduled_call = {}
|
||
|
||
def fake_run_with_schedule(
|
||
task,
|
||
schedule_time,
|
||
run_immediately,
|
||
background_tasks=None,
|
||
schedule_time_provider=None,
|
||
):
|
||
scheduled_call["schedule_time"] = schedule_time
|
||
scheduled_call["run_immediately"] = run_immediately
|
||
scheduled_call["background_tasks"] = background_tasks or []
|
||
scheduled_call["resolved_schedule_time"] = (
|
||
schedule_time_provider() if schedule_time_provider is not None else None
|
||
)
|
||
|
||
with patch("main.parse_arguments", return_value=args), \
|
||
patch("main.get_config", return_value=config), \
|
||
patch("main._reload_runtime_config", return_value=config) as reload_config, \
|
||
patch("main._build_schedule_time_provider", return_value=lambda: "18:00"), \
|
||
patch("main.setup_logging"), \
|
||
patch("main.run_full_analysis") as run_full_analysis, \
|
||
patch("src.services.alert_worker.AlertWorker", return_value=worker) as worker_cls, \
|
||
patch("src.scheduler.run_with_schedule", side_effect=fake_run_with_schedule):
|
||
exit_code = main.main()
|
||
|
||
self.assertEqual(exit_code, 0)
|
||
worker_cls.assert_called_once()
|
||
self.assertIs(worker_cls.call_args.kwargs["config_provider"], reload_config)
|
||
run_full_analysis.assert_not_called()
|
||
self.assertEqual(scheduled_call["schedule_time"], "18:00")
|
||
self.assertEqual(scheduled_call["run_immediately"], True)
|
||
self.assertEqual(scheduled_call["resolved_schedule_time"], "18:00")
|
||
self.assertEqual(len(scheduled_call["background_tasks"]), 1)
|
||
background_task = scheduled_call["background_tasks"][0]
|
||
self.assertEqual(background_task["name"], "agent_event_monitor")
|
||
self.assertEqual(background_task["interval_seconds"], 7 * 60)
|
||
self.assertEqual(background_task["run_immediately"], True)
|
||
|
||
with patch("main.logger.info") as info_log:
|
||
background_task["task"]()
|
||
|
||
worker.run_once.assert_called_once_with()
|
||
info_log.assert_any_call("[EventMonitor] 本轮触发 %d 条提醒", 2)
|
||
|
||
def test_schedule_mode_registers_event_monitor_worker_without_legacy_rules(self) -> None:
|
||
args = self._make_args(schedule=True)
|
||
config = self._make_config(
|
||
schedule_enabled=False,
|
||
agent_event_monitor_enabled=True,
|
||
agent_event_alert_rules_json="",
|
||
)
|
||
worker = MagicMock()
|
||
worker.run_once.return_value = {"triggered": 0}
|
||
scheduled_call = {}
|
||
|
||
def fake_run_with_schedule(
|
||
task,
|
||
schedule_time,
|
||
run_immediately,
|
||
background_tasks=None,
|
||
schedule_time_provider=None,
|
||
):
|
||
scheduled_call["background_tasks"] = background_tasks or []
|
||
|
||
with patch("main.parse_arguments", return_value=args), \
|
||
patch("main.get_config", return_value=config), \
|
||
patch("main._reload_runtime_config", return_value=config), \
|
||
patch("main._build_schedule_time_provider", return_value=lambda: "18:00"), \
|
||
patch("main.setup_logging"), \
|
||
patch("main.run_full_analysis") as run_full_analysis, \
|
||
patch("src.services.alert_worker.AlertWorker", return_value=worker) as worker_cls, \
|
||
patch("src.scheduler.run_with_schedule", side_effect=fake_run_with_schedule):
|
||
exit_code = main.main()
|
||
|
||
self.assertEqual(exit_code, 0)
|
||
worker_cls.assert_called_once()
|
||
run_full_analysis.assert_not_called()
|
||
self.assertEqual(len(scheduled_call["background_tasks"]), 1)
|
||
self.assertEqual(scheduled_call["background_tasks"][0]["name"], "agent_event_monitor")
|
||
|
||
def test_check_notify_returns_before_other_modes(self) -> None:
|
||
args = self._make_args(check_notify=True, serve=True, schedule=True, market_review=True)
|
||
config = self._make_config(webui_enabled=False)
|
||
diagnostic_result = SimpleNamespace(ok=True)
|
||
|
||
with patch("main.parse_arguments", return_value=args), \
|
||
patch("main.get_config", return_value=config), \
|
||
patch("main.setup_logging"), \
|
||
patch("main.start_api_server") as start_api_server, \
|
||
patch("main.run_full_analysis") as run_full_analysis, \
|
||
patch(
|
||
"src.services.notification_diagnostics.run_notification_diagnostics",
|
||
return_value=diagnostic_result,
|
||
) as run_diagnostics, \
|
||
patch(
|
||
"src.services.notification_diagnostics.format_notification_diagnostics",
|
||
return_value="通知配置诊断",
|
||
), \
|
||
patch("builtins.print") as print_output:
|
||
exit_code = main.main()
|
||
|
||
self.assertEqual(exit_code, 0)
|
||
run_diagnostics.assert_called_once_with(config)
|
||
print_output.assert_called_once_with("通知配置诊断")
|
||
start_api_server.assert_not_called()
|
||
run_full_analysis.assert_not_called()
|
||
|
||
def test_serve_mode_exits_when_api_server_start_fails(self) -> None:
|
||
args = self._make_args(serve_only=True, host="127.0.0.1", port=8000)
|
||
config = self._make_config(webui_enabled=False)
|
||
|
||
with patch.dict(os.environ, {"GITHUB_ACTIONS": "false"}, clear=False), \
|
||
patch("main.parse_arguments", return_value=args), \
|
||
patch("main.get_config", return_value=config), \
|
||
patch("main.prepare_webui_frontend_assets", return_value=True), \
|
||
patch("main.start_api_server", side_effect=RuntimeError("port busy")), \
|
||
patch("main.start_bot_stream_clients") as start_bots, \
|
||
patch("main.logger.error") as error_log:
|
||
exit_code = main.main()
|
||
|
||
self.assertEqual(exit_code, 1)
|
||
start_bots.assert_not_called()
|
||
error_log.assert_called_once()
|
||
|
||
def test_webui_only_maps_to_serve_only_and_exits_when_api_server_start_fails(self) -> None:
|
||
args = self._make_args(webui_only=True, host="127.0.0.1", port=8000)
|
||
config = self._make_config(webui_enabled=False)
|
||
|
||
with patch.dict(os.environ, {"GITHUB_ACTIONS": "false"}, clear=False), \
|
||
patch("main.parse_arguments", return_value=args), \
|
||
patch("main.get_config", return_value=config), \
|
||
patch("main.prepare_webui_frontend_assets", return_value=True), \
|
||
patch("main.start_api_server", side_effect=RuntimeError("port busy")), \
|
||
patch("main.start_bot_stream_clients") as start_bots, \
|
||
patch("main.run_full_analysis") as run_full_analysis, \
|
||
patch("main.logger.error") as error_log:
|
||
exit_code = main.main()
|
||
|
||
self.assertEqual(exit_code, 1)
|
||
start_bots.assert_not_called()
|
||
run_full_analysis.assert_not_called()
|
||
error_log.assert_called_once()
|
||
|
||
def test_serve_mode_continues_single_analysis_when_api_server_start_fails(self) -> None:
|
||
args = self._make_args(serve=True, host="127.0.0.1", port=8000)
|
||
config = self._make_config(webui_enabled=False, run_immediately=True)
|
||
|
||
with patch.dict(os.environ, {"GITHUB_ACTIONS": "false"}, clear=False), \
|
||
patch("main.parse_arguments", return_value=args), \
|
||
patch("main.get_config", return_value=config), \
|
||
patch("main.prepare_webui_frontend_assets", return_value=True), \
|
||
patch("main.start_api_server", side_effect=RuntimeError("port busy")), \
|
||
patch("main.start_bot_stream_clients") as start_bots, \
|
||
patch("main.run_full_analysis") as run_full_analysis, \
|
||
patch("main._run_analysis_with_runtime_scheduler_lock") as run_with_lock, \
|
||
patch("main.logger.error") as error_log:
|
||
exit_code = main.main()
|
||
|
||
self.assertEqual(exit_code, 0)
|
||
start_bots.assert_not_called()
|
||
run_with_lock.assert_called_once_with(config, args, None)
|
||
run_full_analysis.assert_not_called()
|
||
error_log.assert_called_once()
|
||
|
||
def test_serve_schedule_mode_continues_scheduler_when_api_server_start_fails(self) -> None:
|
||
args = self._make_args(serve=True, schedule=True, host="127.0.0.1", port=8000)
|
||
config = self._make_config(webui_enabled=False, schedule_enabled=False)
|
||
scheduled_call = {}
|
||
|
||
def fake_run_with_schedule(
|
||
task,
|
||
schedule_time,
|
||
run_immediately,
|
||
background_tasks=None,
|
||
schedule_time_provider=None,
|
||
):
|
||
scheduled_call["schedule_time"] = schedule_time
|
||
scheduled_call["run_immediately"] = run_immediately
|
||
scheduled_call["background_tasks"] = background_tasks or []
|
||
task()
|
||
|
||
with patch.dict(os.environ, {"GITHUB_ACTIONS": "false"}, clear=False), \
|
||
patch("main.parse_arguments", return_value=args), \
|
||
patch("main.get_config", return_value=config), \
|
||
patch("main._reload_runtime_config", return_value=config), \
|
||
patch("main._build_schedule_time_provider", return_value=lambda: "18:00"), \
|
||
patch("main.prepare_webui_frontend_assets", return_value=True), \
|
||
patch("main.start_api_server", side_effect=RuntimeError("port busy")), \
|
||
patch("main.start_bot_stream_clients") as start_bots, \
|
||
patch("main.run_full_analysis") as run_full_analysis, \
|
||
patch("src.scheduler.run_with_schedule", side_effect=fake_run_with_schedule), \
|
||
patch("main.logger.error") as error_log:
|
||
exit_code = main.main()
|
||
|
||
self.assertEqual(exit_code, 0)
|
||
start_bots.assert_not_called()
|
||
run_full_analysis.assert_called_once_with(config, args, None)
|
||
self.assertEqual(scheduled_call["schedule_time"], "18:00")
|
||
self.assertEqual(scheduled_call["run_immediately"], True)
|
||
self.assertEqual(scheduled_call["background_tasks"], [])
|
||
error_log.assert_called_once()
|
||
|
||
def test_serve_with_enabled_schedule_uses_api_runtime_scheduler(self) -> None:
|
||
from src.services.runtime_scheduler import (
|
||
CLI_SCHEDULER_OWNER_ENV,
|
||
RUNTIME_SCHEDULER_ARGS_ENV,
|
||
RUNTIME_SCHEDULER_RUN_IMMEDIATELY_ENV,
|
||
)
|
||
|
||
args = self._make_args(
|
||
serve=True,
|
||
schedule=False,
|
||
host="127.0.0.1",
|
||
port=8000,
|
||
no_notify=True,
|
||
no_market_review=True,
|
||
dry_run=True,
|
||
force_run=True,
|
||
single_notify=True,
|
||
no_context_snapshot=True,
|
||
workers=4,
|
||
)
|
||
config = self._make_config(webui_enabled=False, schedule_enabled=True)
|
||
marker_seen_by_server = []
|
||
run_immediately_seen_by_server = []
|
||
runtime_args_seen_by_server = []
|
||
|
||
def fake_start_api_server(host, port, config):
|
||
marker_seen_by_server.append(os.getenv(CLI_SCHEDULER_OWNER_ENV))
|
||
run_immediately_seen_by_server.append(os.getenv(RUNTIME_SCHEDULER_RUN_IMMEDIATELY_ENV))
|
||
runtime_args_seen_by_server.append(json.loads(os.getenv(RUNTIME_SCHEDULER_ARGS_ENV, "{}")))
|
||
|
||
with patch.dict(
|
||
os.environ,
|
||
{"GITHUB_ACTIONS": "false", CLI_SCHEDULER_OWNER_ENV: "true"},
|
||
clear=False,
|
||
), \
|
||
patch("main.parse_arguments", return_value=args), \
|
||
patch("main.get_config", return_value=config), \
|
||
patch("main.prepare_webui_frontend_assets", return_value=True), \
|
||
patch("main.start_api_server", side_effect=fake_start_api_server), \
|
||
patch("main.start_bot_stream_clients") as start_bots, \
|
||
patch("main.time.sleep", side_effect=KeyboardInterrupt), \
|
||
patch("src.scheduler.run_with_schedule") as run_with_schedule:
|
||
exit_code = main.main()
|
||
|
||
self.assertEqual(exit_code, 0)
|
||
self.assertEqual(marker_seen_by_server, [None])
|
||
self.assertEqual(run_immediately_seen_by_server, ["true"])
|
||
self.assertEqual(runtime_args_seen_by_server, [{
|
||
"no_notify": True,
|
||
"no_market_review": True,
|
||
"dry_run": True,
|
||
"force_run": True,
|
||
"single_notify": True,
|
||
"no_context_snapshot": True,
|
||
"workers": 4,
|
||
}])
|
||
start_bots.assert_called_once_with(config)
|
||
run_with_schedule.assert_not_called()
|
||
|
||
def test_serve_mode_uses_shared_analysis_lock_for_immediate_run_full_analysis(self) -> None:
|
||
args = self._make_args(
|
||
serve=True,
|
||
schedule=False,
|
||
portfolio="futu",
|
||
host="127.0.0.1",
|
||
port=8000,
|
||
)
|
||
config = self._make_config(webui_enabled=False, run_immediately=True)
|
||
|
||
with (
|
||
patch.dict(os.environ, {"GITHUB_ACTIONS": "false"}, clear=False),
|
||
patch("main.parse_arguments", return_value=args),
|
||
patch("main.get_config", return_value=config),
|
||
patch("main.prepare_webui_frontend_assets", return_value=True),
|
||
patch("main.start_api_server"),
|
||
patch("main.start_bot_stream_clients") as start_bots,
|
||
patch("main.time.sleep", side_effect=KeyboardInterrupt),
|
||
patch("main.run_full_analysis") as run_full_analysis,
|
||
patch("main._run_analysis_with_runtime_scheduler_lock") as run_with_lock,
|
||
):
|
||
exit_code = main.main()
|
||
|
||
self.assertEqual(exit_code, 0)
|
||
self.assertEqual(run_with_lock.call_count, 1)
|
||
run_with_lock.assert_called_once_with(config, args, None)
|
||
run_full_analysis.assert_not_called()
|
||
start_bots.assert_called_once_with(config)
|
||
|
||
def test_serve_mode_keeps_running_after_futu_portfolio_load_failure(self) -> None:
|
||
args = self._make_args(
|
||
serve=True,
|
||
schedule=False,
|
||
portfolio="futu",
|
||
host="127.0.0.1",
|
||
port=8000,
|
||
)
|
||
config = self._make_config(webui_enabled=False, run_immediately=True)
|
||
error = FutuPortfolioError("OpenD unavailable")
|
||
|
||
with (
|
||
patch.dict(os.environ, {"GITHUB_ACTIONS": "false"}, clear=False),
|
||
patch("main.parse_arguments", return_value=args),
|
||
patch("main.get_config", return_value=config),
|
||
patch("main.prepare_webui_frontend_assets", return_value=True),
|
||
patch("main.start_api_server"),
|
||
patch("main.start_bot_stream_clients") as start_bots,
|
||
patch("main.time.sleep", side_effect=KeyboardInterrupt),
|
||
patch(
|
||
"main._run_analysis_with_runtime_scheduler_lock",
|
||
side_effect=error,
|
||
) as run_with_lock,
|
||
patch("main.logger.exception") as exception_log,
|
||
):
|
||
exit_code = main.main()
|
||
|
||
self.assertEqual(exit_code, 0)
|
||
run_with_lock.assert_called_once_with(config, args, None)
|
||
start_bots.assert_called_once_with(config)
|
||
exception_log.assert_any_call(
|
||
"Futu 持仓导入失败,Web/API 服务继续运行: %s",
|
||
error,
|
||
)
|
||
|
||
def test_serve_schedule_flag_enables_api_runtime_scheduler(self) -> None:
|
||
from src.services.runtime_scheduler import (
|
||
CLI_SCHEDULER_OWNER_ENV,
|
||
RUNTIME_SCHEDULER_ARGS_ENV,
|
||
RUNTIME_SCHEDULER_FORCE_ENABLED_ENV,
|
||
RUNTIME_SCHEDULER_RUN_IMMEDIATELY_ENV,
|
||
)
|
||
|
||
args = self._make_args(
|
||
serve=True,
|
||
schedule=True,
|
||
host="127.0.0.1",
|
||
port=8000,
|
||
no_notify=True,
|
||
no_market_review=True,
|
||
dry_run=True,
|
||
force_run=True,
|
||
single_notify=True,
|
||
no_context_snapshot=True,
|
||
workers=4,
|
||
)
|
||
config = self._make_config(webui_enabled=False, schedule_enabled=False)
|
||
marker_seen_by_server = []
|
||
force_enabled_seen_by_server = []
|
||
run_immediately_seen_by_server = []
|
||
runtime_args_seen_by_server = []
|
||
|
||
def fake_start_api_server(host, port, config):
|
||
marker_seen_by_server.append(os.getenv(CLI_SCHEDULER_OWNER_ENV))
|
||
force_enabled_seen_by_server.append(os.getenv(RUNTIME_SCHEDULER_FORCE_ENABLED_ENV))
|
||
run_immediately_seen_by_server.append(os.getenv(RUNTIME_SCHEDULER_RUN_IMMEDIATELY_ENV))
|
||
runtime_args_seen_by_server.append(json.loads(os.getenv(RUNTIME_SCHEDULER_ARGS_ENV, "{}")))
|
||
|
||
with patch.dict(os.environ, {"GITHUB_ACTIONS": "false"}, clear=False), \
|
||
patch("main.parse_arguments", return_value=args), \
|
||
patch("main.get_config", return_value=config), \
|
||
patch("main.prepare_webui_frontend_assets", return_value=True), \
|
||
patch("main.start_api_server", side_effect=fake_start_api_server), \
|
||
patch("main.start_bot_stream_clients"), \
|
||
patch("main.time.sleep", side_effect=KeyboardInterrupt), \
|
||
patch("src.scheduler.run_with_schedule") as run_with_schedule:
|
||
exit_code = main.main()
|
||
|
||
self.assertEqual(exit_code, 0)
|
||
self.assertEqual(marker_seen_by_server, [None])
|
||
self.assertEqual(force_enabled_seen_by_server, ["true"])
|
||
self.assertEqual(run_immediately_seen_by_server, ["true"])
|
||
self.assertEqual(runtime_args_seen_by_server, [{
|
||
"no_notify": True,
|
||
"no_market_review": True,
|
||
"dry_run": True,
|
||
"force_run": True,
|
||
"single_notify": True,
|
||
"no_context_snapshot": True,
|
||
"workers": 4,
|
||
}])
|
||
self.assertFalse(config.schedule_enabled)
|
||
run_with_schedule.assert_not_called()
|
||
|
||
def test_serve_schedule_flag_passes_no_run_immediately_to_runtime_scheduler(self) -> None:
|
||
from src.services.runtime_scheduler import RUNTIME_SCHEDULER_RUN_IMMEDIATELY_ENV
|
||
|
||
args = self._make_args(
|
||
serve=True,
|
||
schedule=True,
|
||
no_run_immediately=True,
|
||
host="127.0.0.1",
|
||
port=8000,
|
||
)
|
||
config = self._make_config(webui_enabled=False, schedule_enabled=False)
|
||
run_immediately_seen_by_server = []
|
||
|
||
def fake_start_api_server(host, port, config):
|
||
run_immediately_seen_by_server.append(os.getenv(RUNTIME_SCHEDULER_RUN_IMMEDIATELY_ENV))
|
||
|
||
with patch.dict(os.environ, {"GITHUB_ACTIONS": "false"}, clear=False), \
|
||
patch("main.parse_arguments", return_value=args), \
|
||
patch("main.get_config", return_value=config), \
|
||
patch("main.prepare_webui_frontend_assets", return_value=True), \
|
||
patch("main.start_api_server", side_effect=fake_start_api_server), \
|
||
patch("main.start_bot_stream_clients"), \
|
||
patch("main.time.sleep", side_effect=KeyboardInterrupt), \
|
||
patch("src.scheduler.run_with_schedule") as run_with_schedule:
|
||
exit_code = main.main()
|
||
|
||
self.assertEqual(exit_code, 0)
|
||
self.assertEqual(run_immediately_seen_by_server, ["false"])
|
||
run_with_schedule.assert_not_called()
|
||
|
||
def test_serve_only_restores_persisted_scheduler_without_running_immediately(self) -> None:
|
||
from src.services.runtime_scheduler import (
|
||
CLI_SCHEDULER_OWNER_ENV,
|
||
RUNTIME_SCHEDULER_RUN_IMMEDIATELY_ENV,
|
||
RUNTIME_SCHEDULER_SUPPRESS_START_ENV,
|
||
)
|
||
|
||
args = self._make_args(serve_only=True, host="127.0.0.1", port=8000)
|
||
config = self._make_config(webui_enabled=False, schedule_enabled=True)
|
||
marker_seen_by_server = []
|
||
suppress_seen_by_server = []
|
||
run_immediately_seen_by_server = []
|
||
|
||
def fake_start_api_server(host, port, config):
|
||
marker_seen_by_server.append(os.getenv(CLI_SCHEDULER_OWNER_ENV))
|
||
suppress_seen_by_server.append(os.getenv(RUNTIME_SCHEDULER_SUPPRESS_START_ENV))
|
||
run_immediately_seen_by_server.append(os.getenv(RUNTIME_SCHEDULER_RUN_IMMEDIATELY_ENV))
|
||
|
||
with patch.dict(
|
||
os.environ,
|
||
{"GITHUB_ACTIONS": "false", CLI_SCHEDULER_OWNER_ENV: "true"},
|
||
clear=False,
|
||
), \
|
||
patch("main.parse_arguments", return_value=args), \
|
||
patch("main.get_config", return_value=config), \
|
||
patch("main.prepare_webui_frontend_assets", return_value=True), \
|
||
patch("main.start_api_server", side_effect=fake_start_api_server), \
|
||
patch("main.start_bot_stream_clients") as start_bots, \
|
||
patch("main.time.sleep", side_effect=KeyboardInterrupt), \
|
||
patch("src.scheduler.run_with_schedule") as run_with_schedule:
|
||
exit_code = main.main()
|
||
|
||
self.assertEqual(exit_code, 0)
|
||
self.assertEqual(marker_seen_by_server, [None])
|
||
self.assertEqual(suppress_seen_by_server, [None])
|
||
self.assertEqual(run_immediately_seen_by_server, ["false"])
|
||
start_bots.assert_called_once_with(config)
|
||
run_with_schedule.assert_not_called()
|
||
|
||
def test_reload_runtime_config_preserves_process_env_overrides(self) -> None:
|
||
self.env_path.write_text(
|
||
"OPENAI_API_KEY=stale-file\nSCHEDULE_TIME=09:30\n",
|
||
encoding="utf-8",
|
||
)
|
||
runtime_config = self._make_config(schedule_enabled=True, schedule_time="09:30")
|
||
|
||
with patch.dict(
|
||
os.environ,
|
||
{
|
||
"ENV_FILE": str(self.env_path),
|
||
"OPENAI_API_KEY": "runtime-secret",
|
||
"SCHEDULE_TIME": "18:00",
|
||
},
|
||
clear=False,
|
||
), patch.object(
|
||
main,
|
||
"_INITIAL_PROCESS_ENV",
|
||
{"OPENAI_API_KEY": "runtime-secret"},
|
||
), patch.object(
|
||
main,
|
||
"_RUNTIME_ENV_FILE_KEYS",
|
||
{"SCHEDULE_TIME"},
|
||
), patch(
|
||
"main.get_config",
|
||
return_value=runtime_config,
|
||
) as get_config_mock:
|
||
reloaded_config = main._reload_runtime_config()
|
||
self.assertEqual(os.environ["OPENAI_API_KEY"], "runtime-secret")
|
||
self.assertEqual(os.environ["SCHEDULE_TIME"], "09:30")
|
||
|
||
self.assertIs(reloaded_config, runtime_config)
|
||
get_config_mock.assert_called_once_with()
|
||
|
||
def test_reload_env_file_values_preserves_managed_env_vars_when_read_fails(self) -> None:
|
||
with patch.dict(
|
||
os.environ,
|
||
{
|
||
"ENV_FILE": str(self.env_path),
|
||
"OPENAI_API_KEY": "runtime-secret",
|
||
"SCHEDULE_TIME": "09:30",
|
||
},
|
||
clear=False,
|
||
), patch.object(
|
||
main,
|
||
"_INITIAL_PROCESS_ENV",
|
||
{},
|
||
), patch.object(
|
||
main,
|
||
"_RUNTIME_ENV_FILE_KEYS",
|
||
{"OPENAI_API_KEY", "SCHEDULE_TIME"},
|
||
), patch(
|
||
"main.dotenv_values",
|
||
side_effect=OSError("boom"),
|
||
):
|
||
main._reload_env_file_values_preserving_overrides()
|
||
|
||
self.assertEqual(os.environ["OPENAI_API_KEY"], "runtime-secret")
|
||
self.assertEqual(os.environ["SCHEDULE_TIME"], "09:30")
|
||
self.assertEqual(
|
||
main._RUNTIME_ENV_FILE_KEYS,
|
||
{"OPENAI_API_KEY", "SCHEDULE_TIME"},
|
||
)
|
||
|
||
def test_reload_runtime_config_refreshes_env_before_resetting_singleton(self) -> None:
|
||
runtime_config = self._make_config(schedule_enabled=True, schedule_time="09:30")
|
||
call_order = []
|
||
|
||
def fake_reload_env() -> None:
|
||
call_order.append("reload_env")
|
||
|
||
def fake_reset_instance() -> None:
|
||
call_order.append("reset_instance")
|
||
|
||
def fake_get_config():
|
||
call_order.append("get_config")
|
||
return runtime_config
|
||
|
||
with patch(
|
||
"main._reload_env_file_values_preserving_overrides",
|
||
side_effect=fake_reload_env,
|
||
), patch(
|
||
"main.Config.reset_instance",
|
||
side_effect=fake_reset_instance,
|
||
), patch(
|
||
"main.get_config",
|
||
side_effect=fake_get_config,
|
||
):
|
||
reloaded_config = main._reload_runtime_config()
|
||
|
||
self.assertIs(reloaded_config, runtime_config)
|
||
self.assertEqual(call_order, ["reload_env", "reset_instance", "get_config"])
|
||
|
||
def test_schedule_time_provider_propagates_config_read_failures(self) -> None:
|
||
with patch.object(
|
||
main,
|
||
"_INITIAL_PROCESS_ENV",
|
||
{},
|
||
), patch(
|
||
"src.core.config_manager.ConfigManager.read_config_map",
|
||
side_effect=RuntimeError("boom"),
|
||
):
|
||
provider = main._build_schedule_time_provider("18:00")
|
||
|
||
with self.assertRaisesRegex(RuntimeError, "boom"):
|
||
provider()
|
||
|
||
def test_schedule_time_provider_respects_process_env_precedence(self) -> None:
|
||
with patch.dict(
|
||
os.environ,
|
||
{"SCHEDULE_TIME": "18:00"},
|
||
clear=False,
|
||
), patch.object(
|
||
main,
|
||
"_INITIAL_PROCESS_ENV",
|
||
{"SCHEDULE_TIME": "18:00"},
|
||
), patch(
|
||
"src.core.config_manager.ConfigManager.read_config_map",
|
||
side_effect=AssertionError("should not read .env when process env override exists"),
|
||
):
|
||
provider = main._build_schedule_time_provider("09:30")
|
||
|
||
self.assertEqual(provider(), "18:00")
|
||
|
||
def test_schedule_time_provider_falls_back_to_system_default_on_clear(self) -> None:
|
||
"""When SCHEDULE_TIME is cleared/removed from config, provider returns '18:00'."""
|
||
with patch.dict(
|
||
os.environ,
|
||
{"SCHEDULE_TIME": "09:30"},
|
||
clear=False,
|
||
), patch.object(
|
||
main,
|
||
"_INITIAL_PROCESS_ENV",
|
||
{},
|
||
), patch(
|
||
"src.core.config_manager.ConfigManager.read_config_map",
|
||
return_value={},
|
||
):
|
||
provider = main._build_schedule_time_provider("09:30")
|
||
self.assertEqual(provider(), "18:00")
|
||
|
||
def test_schedule_time_provider_falls_back_to_system_default_on_empty(self) -> None:
|
||
"""When SCHEDULE_TIME is empty string in config, provider returns '18:00'."""
|
||
with patch.dict(
|
||
os.environ,
|
||
{"SCHEDULE_TIME": "09:30"},
|
||
clear=False,
|
||
), patch.object(
|
||
main,
|
||
"_INITIAL_PROCESS_ENV",
|
||
{},
|
||
), patch(
|
||
"src.core.config_manager.ConfigManager.read_config_map",
|
||
return_value={"SCHEDULE_TIME": " "},
|
||
):
|
||
provider = main._build_schedule_time_provider("09:30")
|
||
self.assertEqual(provider(), "18:00")
|
||
|
||
def test_single_run_keeps_cli_stock_override(self) -> None:
|
||
args = self._make_args(stocks="600519,000001")
|
||
config = self._make_config(run_immediately=True)
|
||
|
||
with patch("main.parse_arguments", return_value=args), \
|
||
patch("main.get_config", return_value=config), \
|
||
patch("main.setup_logging"), \
|
||
patch("main.run_full_analysis") as run_full_analysis:
|
||
exit_code = main.main()
|
||
|
||
self.assertEqual(exit_code, 0)
|
||
run_full_analysis.assert_called_once_with(config, args, ["600519", "000001"])
|
||
|
||
def test_run_full_analysis_skips_market_review_when_shared_lock_is_held(self) -> None:
|
||
from src.core.market_review_lock import (
|
||
release_market_review_lock,
|
||
try_acquire_market_review_lock,
|
||
)
|
||
|
||
args = self._make_args()
|
||
config = self._make_config(
|
||
trading_day_check_enabled=False,
|
||
market_review_enabled=True,
|
||
daily_market_context_enabled=True,
|
||
no_market_review=False,
|
||
single_stock_notify=False,
|
||
merge_email_notification=False,
|
||
analysis_delay=0,
|
||
database_path=str(Path(self.temp_dir.name) / "stock_analysis.db"),
|
||
)
|
||
pipeline = MagicMock()
|
||
pipeline.run.return_value = []
|
||
events = []
|
||
pipeline_kwargs = {}
|
||
|
||
def refresh_index(config_arg):
|
||
events.append("refresh")
|
||
|
||
def build_pipeline(*args, **kwargs):
|
||
events.append("pipeline")
|
||
pipeline_kwargs.update(kwargs)
|
||
return pipeline
|
||
|
||
lock_token = try_acquire_market_review_lock(config)
|
||
self.assertIsNotNone(lock_token)
|
||
try:
|
||
with patch.object(main, "_refresh_stock_index_cache_for_analysis", side_effect=refresh_index) as refresh, \
|
||
patch("src.core.pipeline.StockAnalysisPipeline", side_effect=build_pipeline), \
|
||
patch("src.core.market_review.run_market_review") as run_market_review:
|
||
main.run_full_analysis(config, args, [])
|
||
finally:
|
||
release_market_review_lock(lock_token)
|
||
|
||
refresh.assert_called_once_with(config)
|
||
self.assertEqual(events[:2], ["refresh", "pipeline"])
|
||
self.assertTrue(pipeline_kwargs["daily_market_context_allow_generate"])
|
||
pipeline.run.assert_called_once()
|
||
run_market_review.assert_not_called()
|
||
|
||
def test_run_full_analysis_disables_generation_when_no_market_review_flag_set(self) -> None:
|
||
args = self._make_args(no_market_review=True)
|
||
config = self._make_config(
|
||
trading_day_check_enabled=False,
|
||
market_review_enabled=True,
|
||
daily_market_context_enabled=True,
|
||
single_stock_notify=False,
|
||
merge_email_notification=False,
|
||
analysis_delay=0,
|
||
database_path=str(Path(self.temp_dir.name) / "stock_analysis.db"),
|
||
)
|
||
pipeline = MagicMock()
|
||
pipeline.run.return_value = []
|
||
pipeline_kwargs = {}
|
||
|
||
def build_pipeline(*args, **kwargs):
|
||
pipeline_kwargs.update(kwargs)
|
||
return pipeline
|
||
|
||
with patch.object(main, "_refresh_stock_index_cache_for_analysis") as refresh, \
|
||
patch("main._compute_trading_day_filter", return_value=([], "cn", False)), \
|
||
patch("src.core.pipeline.StockAnalysisPipeline", side_effect=build_pipeline), \
|
||
patch("main._prime_daily_market_context") as prime_context, \
|
||
patch("src.core.market_review.run_market_review") as run_market_review:
|
||
main.run_full_analysis(config, args, [])
|
||
|
||
self.assertFalse(pipeline_kwargs["daily_market_context_allow_generate"])
|
||
self.assertEqual(pipeline_kwargs["daily_market_context_enabled"], False)
|
||
prime_context.assert_not_called()
|
||
run_market_review.assert_not_called()
|
||
refresh.assert_called_once_with(config)
|
||
|
||
def test_run_full_analysis_defaults_daily_context_on_without_disabling_market_review(self) -> None:
|
||
args = self._make_args()
|
||
config = self._make_config(
|
||
trading_day_check_enabled=False,
|
||
market_review_enabled=True,
|
||
single_stock_notify=False,
|
||
merge_email_notification=False,
|
||
analysis_delay=0,
|
||
database_path=str(Path(self.temp_dir.name) / "stock_analysis.db"),
|
||
)
|
||
pipeline = MagicMock()
|
||
pipeline.run.return_value = []
|
||
pipeline_kwargs = {}
|
||
|
||
def build_pipeline(*args, **kwargs):
|
||
pipeline_kwargs.update(kwargs)
|
||
return pipeline
|
||
|
||
with patch.object(main, "_refresh_stock_index_cache_for_analysis") as refresh, \
|
||
patch("main._compute_trading_day_filter", return_value=([], "cn", False)), \
|
||
patch("src.core.pipeline.StockAnalysisPipeline", side_effect=build_pipeline), \
|
||
patch("main._prime_daily_market_context", side_effect=[("", ""), ("缓存摘要", "完整复盘")]) as prime_context, \
|
||
patch("main._run_market_review_with_shared_lock", return_value=SimpleNamespace(report="大盘复盘")) as run_with_lock:
|
||
main.run_full_analysis(config, args, [])
|
||
|
||
self.assertTrue(pipeline_kwargs["daily_market_context_enabled"])
|
||
self.assertTrue(pipeline_kwargs["daily_market_context_allow_generate"])
|
||
self.assertEqual(prime_context.call_count, 2)
|
||
run_with_lock.assert_called_once()
|
||
refresh.assert_called_once_with(config)
|
||
|
||
def test_run_full_analysis_primes_daily_market_context_before_stock_analysis(self) -> None:
|
||
args = self._make_args()
|
||
target_date = date(2026, 3, 26)
|
||
reference_times = []
|
||
config = self._make_config(
|
||
trading_day_check_enabled=False,
|
||
market_review_enabled=True,
|
||
daily_market_context_enabled=True,
|
||
single_stock_notify=False,
|
||
merge_email_notification=False,
|
||
analysis_delay=0,
|
||
database_path=str(Path(self.temp_dir.name) / "stock_analysis.db"),
|
||
)
|
||
pipeline = MagicMock()
|
||
pipeline.run.return_value = []
|
||
pipeline_kwargs = {}
|
||
|
||
def build_pipeline(*args, **kwargs):
|
||
pipeline_kwargs.update(kwargs)
|
||
return pipeline
|
||
|
||
def resolve_target_date(region, current_time):
|
||
self.assertEqual(region, "cn")
|
||
reference_times.append(current_time)
|
||
return target_date
|
||
|
||
with patch.object(main, "_refresh_stock_index_cache_for_analysis") as refresh, \
|
||
patch("main._compute_trading_day_filter", return_value=([], "cn", False)), \
|
||
patch("main._resolve_daily_market_context_target_date", side_effect=resolve_target_date), \
|
||
patch("src.core.pipeline.StockAnalysisPipeline", side_effect=build_pipeline), \
|
||
patch("main._prime_daily_market_context", return_value=("大盘退潮,高风险,建议观望,仓位上限30%。", "完整复盘正文")) as prime_context, \
|
||
patch("main._run_market_review_with_shared_lock") as run_with_lock, \
|
||
patch("src.core.market_review.run_market_review") as run_market_review:
|
||
main.run_full_analysis(config, args, [])
|
||
|
||
self.assertTrue(pipeline_kwargs["daily_market_context_allow_generate"])
|
||
prime_context.assert_has_calls(
|
||
[
|
||
unittest.mock.call(
|
||
config,
|
||
pipeline=pipeline,
|
||
region="cn",
|
||
no_market_review=False,
|
||
allow_generate=False,
|
||
target_date=target_date,
|
||
return_full_report=False,
|
||
),
|
||
unittest.mock.call(
|
||
config,
|
||
pipeline=pipeline,
|
||
region="cn",
|
||
no_market_review=False,
|
||
allow_generate=False,
|
||
target_date=target_date,
|
||
return_full_report=True,
|
||
require_current_query_match=True,
|
||
),
|
||
]
|
||
)
|
||
self.assertEqual(len(reference_times), 1)
|
||
self.assertIs(pipeline.run.call_args.kwargs["current_time"], reference_times[0])
|
||
self.assertEqual(pipeline.run.call_args.kwargs["current_time"].tzinfo, timezone.utc)
|
||
run_with_lock.assert_called_once()
|
||
self.assertFalse(run_with_lock.call_args.kwargs["merge_notification"])
|
||
self.assertTrue(run_with_lock.call_args.kwargs["send_notification"])
|
||
run_market_review.assert_not_called()
|
||
refresh.assert_called_once_with(config)
|
||
pipeline.run.assert_called_once()
|
||
|
||
def test_resolve_daily_market_context_target_date_passes_jp_kr_to_trading_calendar(self) -> None:
|
||
current_time = datetime(2026, 3, 26, 12, 0, tzinfo=timezone.utc)
|
||
target_date = date(2026, 3, 25)
|
||
|
||
with patch("src.core.trading_calendar.get_effective_trading_date", return_value=target_date) as get_date:
|
||
self.assertEqual(
|
||
main._resolve_daily_market_context_target_date("jp", current_time),
|
||
target_date,
|
||
)
|
||
self.assertEqual(
|
||
main._resolve_daily_market_context_target_date("kr", current_time),
|
||
target_date,
|
||
)
|
||
|
||
self.assertEqual(
|
||
get_date.call_args_list,
|
||
[
|
||
unittest.mock.call("jp", current_time=current_time),
|
||
unittest.mock.call("kr", current_time=current_time),
|
||
],
|
||
)
|
||
|
||
def test_run_full_analysis_does_not_reuse_single_context_for_multi_market_review(self) -> None:
|
||
args = self._make_args()
|
||
target_date = date(2026, 3, 26)
|
||
config = self._make_config(
|
||
trading_day_check_enabled=True,
|
||
market_review_region="both",
|
||
market_review_enabled=True,
|
||
daily_market_context_enabled=True,
|
||
single_stock_notify=False,
|
||
merge_email_notification=False,
|
||
analysis_delay=0,
|
||
database_path=str(Path(self.temp_dir.name) / "stock_analysis.db"),
|
||
)
|
||
pipeline = MagicMock()
|
||
pipeline.run.return_value = []
|
||
pipeline_kwargs = {}
|
||
|
||
def build_pipeline(*args, **kwargs):
|
||
pipeline_kwargs.update(kwargs)
|
||
return pipeline
|
||
|
||
with patch.object(main, "_refresh_stock_index_cache_for_analysis") as refresh, \
|
||
patch("main._compute_trading_day_filter", return_value=([], "cn,us", False)), \
|
||
patch("main._resolve_daily_market_context_target_date", return_value=target_date), \
|
||
patch("src.core.pipeline.StockAnalysisPipeline", side_effect=build_pipeline), \
|
||
patch("main._prime_daily_market_context", return_value=("A股缓存摘要", "")) as prime_context, \
|
||
patch("main._run_market_review_with_shared_lock", return_value="多市场复盘") as run_with_lock, \
|
||
patch("src.core.market_review.run_market_review") as run_market_review:
|
||
main.run_full_analysis(config, args, [])
|
||
|
||
self.assertTrue(pipeline_kwargs["daily_market_context_allow_generate"])
|
||
prime_context.assert_has_calls(
|
||
[
|
||
unittest.mock.call(
|
||
config,
|
||
pipeline=pipeline,
|
||
region="cn,us",
|
||
no_market_review=False,
|
||
allow_generate=False,
|
||
target_date=target_date,
|
||
return_full_report=False,
|
||
),
|
||
unittest.mock.call(
|
||
config,
|
||
pipeline=pipeline,
|
||
region="cn,us",
|
||
no_market_review=False,
|
||
allow_generate=False,
|
||
target_date=target_date,
|
||
return_full_report=True,
|
||
require_current_query_match=True,
|
||
),
|
||
]
|
||
)
|
||
run_with_lock.assert_called_once()
|
||
self.assertEqual(run_with_lock.call_args.kwargs["override_region"], "cn,us")
|
||
run_market_review.assert_not_called()
|
||
refresh.assert_called_once_with(config)
|
||
pipeline.run.assert_called_once()
|
||
|
||
def test_prime_daily_market_context_readonly_mode_still_reuses_cached_context(self) -> None:
|
||
target_date = date(2026, 3, 26)
|
||
config = self._make_config(
|
||
trading_day_check_enabled=False,
|
||
market_review_enabled=True,
|
||
market_review_region="cn",
|
||
single_stock_notify=False,
|
||
merge_email_notification=False,
|
||
analysis_delay=0,
|
||
database_path=str(Path(self.temp_dir.name) / "stock_analysis.db"),
|
||
)
|
||
pipeline = MagicMock()
|
||
pipeline._daily_market_context_service = None
|
||
pipeline.db = MagicMock()
|
||
pipeline.query_id = "prime-query"
|
||
context = SimpleNamespace(source="analysis_history", summary="历史复盘摘要")
|
||
service = MagicMock()
|
||
service.get_context.return_value = context
|
||
|
||
with patch(
|
||
"src.services.daily_market_context.DailyMarketContextService",
|
||
return_value=service,
|
||
) as service_cls:
|
||
summary, full_report = main._prime_daily_market_context(
|
||
config,
|
||
pipeline=pipeline,
|
||
region="cn",
|
||
no_market_review=False,
|
||
allow_generate=False,
|
||
target_date=target_date,
|
||
return_full_report=True,
|
||
)
|
||
|
||
self.assertEqual(summary, "历史复盘摘要")
|
||
self.assertEqual(full_report, "")
|
||
service_cls.assert_called_once_with(db_manager=pipeline.db)
|
||
call_kwargs = service.get_context.call_args.kwargs
|
||
self.assertEqual(call_kwargs["region"], "cn")
|
||
self.assertFalse(call_kwargs["force_refresh"])
|
||
self.assertFalse(call_kwargs["allow_generate"])
|
||
self.assertFalse(call_kwargs["persist_market_review_history"])
|
||
self.assertEqual(call_kwargs["target_date"], target_date)
|
||
self.assertEqual(call_kwargs["current_query_id"], "prime-query")
|
||
|
||
def test_prime_daily_market_context_query_fallback_reuses_runtime_context(self) -> None:
|
||
target_date = date(2026, 3, 26)
|
||
config = self._make_config(
|
||
trading_day_check_enabled=False,
|
||
market_review_enabled=True,
|
||
market_review_region="cn",
|
||
single_stock_notify=False,
|
||
merge_email_notification=True,
|
||
analysis_delay=0,
|
||
database_path=str(Path(self.temp_dir.name) / "stock_analysis.db"),
|
||
)
|
||
pipeline = MagicMock()
|
||
pipeline._daily_market_context_service = None
|
||
pipeline.db = MagicMock()
|
||
pipeline.query_id = "prime-query"
|
||
context = SimpleNamespace(
|
||
source="market_review_runtime",
|
||
summary="本轮运行时复盘摘要",
|
||
full_report="本轮运行时完整复盘",
|
||
query_id="prime-query",
|
||
)
|
||
service = MagicMock()
|
||
service.get_context.return_value = context
|
||
|
||
with patch(
|
||
"src.services.daily_market_context.DailyMarketContextService",
|
||
return_value=service,
|
||
):
|
||
summary, full_report = main._prime_daily_market_context(
|
||
config,
|
||
pipeline=pipeline,
|
||
region="cn",
|
||
no_market_review=False,
|
||
allow_generate=False,
|
||
target_date=target_date,
|
||
return_full_report=True,
|
||
require_current_query_match=True,
|
||
)
|
||
|
||
self.assertEqual(summary, "本轮运行时复盘摘要")
|
||
self.assertEqual(full_report, "本轮运行时完整复盘")
|
||
self.assertTrue(service.get_context.call_args.kwargs["require_query_id_match"])
|
||
|
||
def test_run_full_analysis_generates_full_market_review_once_after_stock_analysis(self) -> None:
|
||
args = self._make_args()
|
||
target_date = date(2026, 3, 26)
|
||
config = self._make_config(
|
||
trading_day_check_enabled=False,
|
||
market_review_enabled=True,
|
||
daily_market_context_enabled=True,
|
||
single_stock_notify=False,
|
||
merge_email_notification=False,
|
||
analysis_delay=0,
|
||
database_path=str(Path(self.temp_dir.name) / "stock_analysis.db"),
|
||
)
|
||
pipeline = MagicMock()
|
||
events = []
|
||
pipeline.run.side_effect = lambda **kwargs: events.append("stock-run") or []
|
||
pipeline_kwargs = {}
|
||
|
||
def build_pipeline(*args, **kwargs):
|
||
events.append("pipeline")
|
||
pipeline_kwargs.update(kwargs)
|
||
return pipeline
|
||
|
||
def run_with_lock(*args, **kwargs):
|
||
events.append("market-review")
|
||
return SimpleNamespace(report="完整复盘")
|
||
|
||
with patch.object(main, "_refresh_stock_index_cache_for_analysis") as refresh, \
|
||
patch("main._compute_trading_day_filter", return_value=([], "cn", False)), \
|
||
patch("main._resolve_daily_market_context_target_date", return_value=target_date), \
|
||
patch("src.core.pipeline.StockAnalysisPipeline", side_effect=build_pipeline), \
|
||
patch("main._prime_daily_market_context", return_value=("", "")) as prime_context, \
|
||
patch("main._run_market_review_with_shared_lock", side_effect=run_with_lock) as run_with_lock_mock, \
|
||
patch("src.core.market_review.run_market_review") as run_market_review:
|
||
main.run_full_analysis(config, args, [])
|
||
|
||
self.assertTrue(pipeline_kwargs["daily_market_context_allow_generate"])
|
||
self.assertEqual(events, ["pipeline", "stock-run", "market-review"])
|
||
query_scoped_read = unittest.mock.call(
|
||
config,
|
||
pipeline=pipeline,
|
||
region="cn",
|
||
no_market_review=False,
|
||
allow_generate=False,
|
||
target_date=target_date,
|
||
return_full_report=True,
|
||
require_current_query_match=True,
|
||
)
|
||
self.assertEqual(
|
||
prime_context.call_args_list,
|
||
[
|
||
unittest.mock.call(
|
||
config,
|
||
pipeline=pipeline,
|
||
region="cn",
|
||
no_market_review=False,
|
||
allow_generate=False,
|
||
target_date=target_date,
|
||
return_full_report=False,
|
||
),
|
||
query_scoped_read,
|
||
query_scoped_read,
|
||
],
|
||
)
|
||
run_with_lock_mock.assert_called_once()
|
||
run_market_review.assert_not_called()
|
||
refresh.assert_called_once_with(config)
|
||
pipeline.run.assert_called_once()
|
||
|
||
def test_run_full_analysis_reuses_runtime_market_context_after_stock_analysis(self) -> None:
|
||
args = self._make_args()
|
||
target_date = date(2026, 3, 26)
|
||
config = self._make_config(
|
||
trading_day_check_enabled=False,
|
||
market_review_enabled=True,
|
||
daily_market_context_enabled=True,
|
||
single_stock_notify=False,
|
||
merge_email_notification=False,
|
||
analysis_delay=0,
|
||
database_path=str(Path(self.temp_dir.name) / "stock_analysis.db"),
|
||
)
|
||
pipeline = MagicMock()
|
||
pipeline.run.return_value = []
|
||
pipeline.notifier = MagicMock(
|
||
is_available=MagicMock(return_value=True),
|
||
send=MagicMock(return_value=True),
|
||
)
|
||
pipeline_kwargs = {}
|
||
|
||
def build_pipeline(*args, **kwargs):
|
||
pipeline_kwargs.update(kwargs)
|
||
return pipeline
|
||
|
||
runtime_context = ("本轮运行时复盘摘要", "## 本轮运行时完整复盘")
|
||
with patch.object(main, "_refresh_stock_index_cache_for_analysis") as refresh, \
|
||
patch("main._compute_trading_day_filter", return_value=([], "cn", False)), \
|
||
patch("main._resolve_daily_market_context_target_date", return_value=target_date), \
|
||
patch("src.core.pipeline.StockAnalysisPipeline", side_effect=build_pipeline), \
|
||
patch(
|
||
"main._prime_daily_market_context",
|
||
side_effect=[("", ""), ("", ""), runtime_context],
|
||
) as prime_context, \
|
||
patch("main._run_market_review_with_shared_lock") as run_with_lock, \
|
||
patch("src.core.market_review.run_market_review") as run_market_review:
|
||
main.run_full_analysis(config, args, [])
|
||
|
||
self.assertTrue(pipeline_kwargs["daily_market_context_allow_generate"])
|
||
run_with_lock.assert_not_called()
|
||
run_market_review.assert_not_called()
|
||
pipeline.notifier.send.assert_called_once()
|
||
self.assertIn("## 本轮运行时完整复盘", pipeline.notifier.send.call_args.args[0])
|
||
self.assertEqual(pipeline.notifier.send.call_args.kwargs["route_type"], "report")
|
||
query_scoped_read = unittest.mock.call(
|
||
config,
|
||
pipeline=pipeline,
|
||
region="cn",
|
||
no_market_review=False,
|
||
allow_generate=False,
|
||
target_date=target_date,
|
||
return_full_report=True,
|
||
require_current_query_match=True,
|
||
)
|
||
self.assertEqual(
|
||
prime_context.call_args_list,
|
||
[
|
||
unittest.mock.call(
|
||
config,
|
||
pipeline=pipeline,
|
||
region="cn",
|
||
no_market_review=False,
|
||
allow_generate=False,
|
||
target_date=target_date,
|
||
return_full_report=False,
|
||
),
|
||
query_scoped_read,
|
||
query_scoped_read,
|
||
],
|
||
)
|
||
refresh.assert_called_once_with(config)
|
||
pipeline.run.assert_called_once()
|
||
|
||
def test_run_full_analysis_saves_reused_runtime_market_context_without_notify(self) -> None:
|
||
args = self._make_args(no_notify=True)
|
||
target_date = date(2026, 3, 26)
|
||
config = self._make_config(
|
||
trading_day_check_enabled=False,
|
||
market_review_enabled=True,
|
||
daily_market_context_enabled=True,
|
||
single_stock_notify=False,
|
||
merge_email_notification=False,
|
||
analysis_delay=0,
|
||
database_path=str(Path(self.temp_dir.name) / "stock_analysis.db"),
|
||
)
|
||
pipeline = MagicMock()
|
||
pipeline.run.return_value = []
|
||
pipeline.notifier = MagicMock()
|
||
pipeline.notifier.save_report_to_file.return_value = "/tmp/market_review.md"
|
||
|
||
def build_pipeline(*args, **kwargs):
|
||
return pipeline
|
||
|
||
runtime_context = ("本轮运行时复盘摘要", "## 本轮运行时完整复盘")
|
||
with (
|
||
patch.object(main, "_refresh_stock_index_cache_for_analysis") as refresh,
|
||
patch("main._compute_trading_day_filter", return_value=([], "cn", False)),
|
||
patch("main._resolve_daily_market_context_target_date", return_value=target_date),
|
||
patch("src.core.pipeline.StockAnalysisPipeline", side_effect=build_pipeline),
|
||
patch(
|
||
"main._prime_daily_market_context",
|
||
side_effect=[("", ""), ("", ""), runtime_context],
|
||
) as prime_context,
|
||
patch("main._run_market_review_with_shared_lock") as run_with_lock,
|
||
patch("src.core.market_review.run_market_review") as run_market_review,
|
||
):
|
||
main.run_full_analysis(config, args, [])
|
||
|
||
run_with_lock.assert_not_called()
|
||
run_market_review.assert_not_called()
|
||
pipeline.notifier.send.assert_not_called()
|
||
pipeline.notifier.save_report_to_file.assert_called_once()
|
||
saved_content, saved_filename = pipeline.notifier.save_report_to_file.call_args.args
|
||
self.assertTrue(saved_content.startswith("# 🎯 大盘复盘\n\n"))
|
||
self.assertIn("## 本轮运行时完整复盘", saved_content)
|
||
self.assertTrue(saved_filename.startswith("market_review_"))
|
||
self.assertTrue(saved_filename.endswith(".md"))
|
||
self.assertEqual(prime_context.call_count, 3)
|
||
refresh.assert_called_once_with(config)
|
||
pipeline.run.assert_called_once()
|
||
|
||
def test_run_full_analysis_still_runs_market_review_for_merge_disabled_with_reused_context(self) -> None:
|
||
args = self._make_args()
|
||
target_date = date(2026, 3, 26)
|
||
config = self._make_config(
|
||
trading_day_check_enabled=False,
|
||
market_review_enabled=True,
|
||
daily_market_context_enabled=True,
|
||
single_stock_notify=False,
|
||
merge_email_notification=False,
|
||
analysis_delay=0,
|
||
database_path=str(Path(self.temp_dir.name) / "stock_analysis.db"),
|
||
)
|
||
pipeline = MagicMock()
|
||
pipeline.run.return_value = []
|
||
pipeline_kwargs = {}
|
||
|
||
def build_pipeline(*args, **kwargs):
|
||
pipeline_kwargs.update(kwargs)
|
||
return pipeline
|
||
|
||
with patch.object(main, "_refresh_stock_index_cache_for_analysis") as refresh, \
|
||
patch("main._compute_trading_day_filter", return_value=([], "cn", False)), \
|
||
patch("main._resolve_daily_market_context_target_date", return_value=target_date), \
|
||
patch("src.core.pipeline.StockAnalysisPipeline", side_effect=build_pipeline), \
|
||
patch(
|
||
"main._prime_daily_market_context",
|
||
return_value=("大盘退潮,高风险,建议观望。", "## 完整大盘复盘\n市场结构偏弱,建议保守。"),
|
||
) as prime_context, \
|
||
patch("main._run_market_review_with_shared_lock") as run_with_lock, \
|
||
patch("src.core.market_review.run_market_review") as run_market_review:
|
||
main.run_full_analysis(config, args, [])
|
||
|
||
self.assertTrue(pipeline_kwargs["daily_market_context_allow_generate"])
|
||
prime_context.assert_has_calls(
|
||
[
|
||
unittest.mock.call(
|
||
config,
|
||
pipeline=pipeline,
|
||
region="cn",
|
||
no_market_review=False,
|
||
allow_generate=False,
|
||
target_date=target_date,
|
||
return_full_report=False,
|
||
),
|
||
unittest.mock.call(
|
||
config,
|
||
pipeline=pipeline,
|
||
region="cn",
|
||
no_market_review=False,
|
||
allow_generate=False,
|
||
target_date=target_date,
|
||
return_full_report=True,
|
||
require_current_query_match=True,
|
||
),
|
||
]
|
||
)
|
||
run_with_lock.assert_called_once()
|
||
self.assertFalse(run_with_lock.call_args.kwargs["merge_notification"])
|
||
run_market_review.assert_not_called()
|
||
refresh.assert_called_once_with(config)
|
||
pipeline.run.assert_called_once()
|
||
|
||
def test_run_full_analysis_waits_for_analysis_delay_before_market_review(self) -> None:
|
||
args = self._make_args()
|
||
target_date = date(2026, 3, 26)
|
||
config = self._make_config(
|
||
trading_day_check_enabled=False,
|
||
market_review_enabled=True,
|
||
daily_market_context_enabled=True,
|
||
single_stock_notify=False,
|
||
merge_email_notification=False,
|
||
analysis_delay=2,
|
||
database_path=str(Path(self.temp_dir.name) / "stock_analysis.db"),
|
||
)
|
||
pipeline = MagicMock()
|
||
events = []
|
||
pipeline.run.side_effect = lambda **kwargs: events.append("stock-run") or []
|
||
pipeline_kwargs = {}
|
||
|
||
def build_pipeline(*args, **kwargs):
|
||
events.append("pipeline")
|
||
pipeline_kwargs.update(kwargs)
|
||
return pipeline
|
||
|
||
def run_with_lock(*args, **kwargs):
|
||
events.append("market-review")
|
||
return SimpleNamespace(report="完整复盘")
|
||
|
||
with patch.object(main, "_refresh_stock_index_cache_for_analysis") as refresh, \
|
||
patch("main._compute_trading_day_filter", return_value=([], "cn", False)), \
|
||
patch("main._resolve_daily_market_context_target_date", return_value=target_date), \
|
||
patch("src.core.pipeline.StockAnalysisPipeline", side_effect=build_pipeline), \
|
||
patch("main._prime_daily_market_context", return_value=("", "")) as prime_context, \
|
||
patch("main._run_market_review_with_shared_lock", side_effect=run_with_lock) as run_with_lock_mock, \
|
||
patch("time.sleep") as sleep, \
|
||
patch("src.core.market_review.run_market_review") as run_market_review:
|
||
main.run_full_analysis(config, args, [])
|
||
|
||
self.assertTrue(pipeline_kwargs["daily_market_context_allow_generate"])
|
||
self.assertEqual(events, ["pipeline", "stock-run", "market-review"])
|
||
self.assertEqual(sleep.call_count, 1)
|
||
sleep.assert_called_once_with(2)
|
||
self.assertEqual(
|
||
run_with_lock_mock.call_args.kwargs["send_notification"],
|
||
True,
|
||
)
|
||
run_market_review.assert_not_called()
|
||
run_with_lock_mock.assert_called_once()
|
||
refresh.assert_called_once_with(config)
|
||
prime_context.assert_has_calls(
|
||
[
|
||
unittest.mock.call(
|
||
config,
|
||
pipeline=pipeline,
|
||
region="cn",
|
||
no_market_review=False,
|
||
allow_generate=False,
|
||
target_date=target_date,
|
||
return_full_report=False,
|
||
),
|
||
unittest.mock.call(
|
||
config,
|
||
pipeline=pipeline,
|
||
region="cn",
|
||
no_market_review=False,
|
||
allow_generate=False,
|
||
target_date=target_date,
|
||
return_full_report=True,
|
||
require_current_query_match=True,
|
||
),
|
||
unittest.mock.call(
|
||
config,
|
||
pipeline=pipeline,
|
||
region="cn",
|
||
no_market_review=False,
|
||
allow_generate=False,
|
||
target_date=target_date,
|
||
return_full_report=True,
|
||
require_current_query_match=True,
|
||
),
|
||
]
|
||
)
|
||
|
||
def test_run_full_analysis_reuses_cached_market_context_as_full_report(self) -> None:
|
||
args = self._make_args()
|
||
target_date = date(2026, 3, 26)
|
||
config = self._make_config(
|
||
trading_day_check_enabled=False,
|
||
market_review_enabled=True,
|
||
daily_market_context_enabled=True,
|
||
single_stock_notify=False,
|
||
merge_email_notification=True,
|
||
analysis_delay=0,
|
||
database_path=str(Path(self.temp_dir.name) / "stock_analysis.db"),
|
||
report_type="simple",
|
||
)
|
||
pipeline = MagicMock()
|
||
pipeline.run.return_value = []
|
||
pipeline.notifier = MagicMock(
|
||
is_available=MagicMock(return_value=True),
|
||
generate_aggregate_report=MagicMock(return_value=""),
|
||
send=MagicMock(return_value=True),
|
||
)
|
||
pipeline_kwargs = {}
|
||
|
||
def build_pipeline(*args, **kwargs):
|
||
pipeline_kwargs.update(kwargs)
|
||
return pipeline
|
||
|
||
with patch.object(main, "_refresh_stock_index_cache_for_analysis") as refresh, \
|
||
patch("main._compute_trading_day_filter", return_value=([], "cn", False)), \
|
||
patch("main._resolve_daily_market_context_target_date", return_value=target_date), \
|
||
patch("src.core.pipeline.StockAnalysisPipeline", side_effect=build_pipeline), \
|
||
patch(
|
||
"main._prime_daily_market_context",
|
||
return_value=(
|
||
"大盘退潮,高风险,建议观望。",
|
||
"## 完整大盘复盘\n市场结构偏弱,建议保守。",
|
||
),
|
||
) as prime_context, \
|
||
patch("main._run_market_review_with_shared_lock") as run_with_lock, \
|
||
patch("src.core.market_review.run_market_review") as run_market_review:
|
||
main.run_full_analysis(config, args, [])
|
||
|
||
self.assertTrue(pipeline_kwargs["daily_market_context_allow_generate"])
|
||
prime_context.assert_has_calls(
|
||
[
|
||
unittest.mock.call(
|
||
config,
|
||
pipeline=pipeline,
|
||
region="cn",
|
||
no_market_review=False,
|
||
allow_generate=False,
|
||
target_date=target_date,
|
||
return_full_report=False,
|
||
),
|
||
unittest.mock.call(
|
||
config,
|
||
pipeline=pipeline,
|
||
region="cn",
|
||
no_market_review=False,
|
||
allow_generate=False,
|
||
target_date=target_date,
|
||
return_full_report=True,
|
||
require_current_query_match=True,
|
||
),
|
||
]
|
||
)
|
||
run_with_lock.assert_not_called()
|
||
run_market_review.assert_not_called()
|
||
refresh.assert_called_once_with(config)
|
||
pipeline.run.assert_called_once_with(
|
||
stock_codes=[],
|
||
dry_run=False,
|
||
send_notification=True,
|
||
merge_notification=True,
|
||
current_time=unittest.mock.ANY,
|
||
)
|
||
notifier_message = pipeline.notifier.send.call_args.args[0]
|
||
self.assertIn("## 完整大盘复盘", notifier_message)
|
||
self.assertNotIn("大盘退潮,高风险,建议观望。", notifier_message)
|
||
|
||
def test_run_market_review_with_shared_lock_forwards_request_config(self) -> None:
|
||
config = self._make_config(
|
||
trading_day_check_enabled=False,
|
||
market_review_enabled=True,
|
||
daily_market_context_enabled=True,
|
||
single_stock_notify=False,
|
||
merge_email_notification=False,
|
||
analysis_delay=0,
|
||
database_path=str(Path(self.temp_dir.name) / "stock_analysis.db"),
|
||
)
|
||
run_review = MagicMock(return_value="复盘结果")
|
||
|
||
with patch("src.core.market_review_lock.try_acquire_market_review_lock", return_value=object()) as acquire_lock, \
|
||
patch("src.core.market_review_lock.release_market_review_lock") as release_lock:
|
||
result = main._run_market_review_with_shared_lock(
|
||
config,
|
||
run_review,
|
||
send_notification=False,
|
||
)
|
||
|
||
self.assertEqual(result, "复盘结果")
|
||
acquire_lock.assert_called_once_with(config)
|
||
run_review.assert_called_once_with(config=config, send_notification=False)
|
||
release_lock.assert_called_once_with(unittest.mock.ANY)
|
||
|
||
def test_prime_daily_market_context_uses_ephemeral_service_for_multi_market_region(self) -> None:
|
||
config = self._make_config(
|
||
trading_day_check_enabled=False,
|
||
market_review_enabled=True,
|
||
single_stock_notify=False,
|
||
merge_email_notification=False,
|
||
analysis_delay=0,
|
||
database_path=str(Path(self.temp_dir.name) / "stock_analysis.db"),
|
||
)
|
||
pipeline = MagicMock()
|
||
pipeline._daily_market_context_service = MagicMock()
|
||
pipeline._daily_market_context_service.get_context.return_value = SimpleNamespace(
|
||
source="analysis_history",
|
||
summary="旧A股复盘摘要",
|
||
)
|
||
pipeline.db = MagicMock()
|
||
context = SimpleNamespace(source="analysis_history", summary="多市场复盘摘要", full_report="完整复盘正文")
|
||
regional_service = MagicMock()
|
||
regional_service.get_context.return_value = context
|
||
|
||
with patch("src.services.daily_market_context.DailyMarketContextService", return_value=regional_service) as service_cls:
|
||
summary, full_report = main._prime_daily_market_context(
|
||
config,
|
||
pipeline=pipeline,
|
||
region="cn,us",
|
||
no_market_review=False,
|
||
allow_generate=False,
|
||
target_date=date(2026, 3, 26),
|
||
return_full_report=True,
|
||
)
|
||
|
||
self.assertEqual(summary, "多市场复盘摘要")
|
||
self.assertEqual(full_report, "完整复盘正文")
|
||
service_cls.assert_called_once_with(db_manager=pipeline.db)
|
||
regional_service.get_context.assert_called_once()
|
||
self.assertIsNot(
|
||
regional_service,
|
||
pipeline._daily_market_context_service,
|
||
"多市场预热必须使用独立服务避免共享缓存污染",
|
||
)
|
||
pipeline._daily_market_context_service.get_context.assert_not_called()
|
||
|
||
get_context_kwargs = regional_service.get_context.call_args.kwargs
|
||
self.assertEqual(get_context_kwargs["region"], "cn,us")
|
||
self.assertFalse(get_context_kwargs["force_refresh"])
|
||
self.assertFalse(get_context_kwargs["allow_generate"])
|
||
self.assertFalse(get_context_kwargs["persist_market_review_history"])
|
||
|
||
def test_config_enabled_schedule_marks_market_review_source_as_schedule(self) -> None:
|
||
args = self._make_args(schedule=False)
|
||
target_date = date(2026, 3, 26)
|
||
config = self._make_config(
|
||
schedule_enabled=True,
|
||
trading_day_check_enabled=False,
|
||
market_review_enabled=True,
|
||
no_market_review=False,
|
||
single_stock_notify=False,
|
||
merge_email_notification=False,
|
||
analysis_delay=0,
|
||
database_path=str(Path(self.temp_dir.name) / "stock_analysis.db"),
|
||
)
|
||
pipeline = MagicMock()
|
||
pipeline.run.return_value = []
|
||
|
||
with patch.object(main, "_refresh_stock_index_cache_for_analysis"), \
|
||
patch.object(main, "_compute_trading_day_filter", return_value=(["600519"], "cn", False)), \
|
||
patch("main._resolve_daily_market_context_target_date", return_value=target_date), \
|
||
patch("src.core.pipeline.StockAnalysisPipeline", return_value=pipeline), \
|
||
patch("main._prime_daily_market_context", return_value=("", "")), \
|
||
patch("main._run_market_review_with_shared_lock", return_value="market report") as run_with_lock, \
|
||
patch("src.core.market_review.run_market_review") as run_market_review:
|
||
main.run_full_analysis(config, args, ["600519"])
|
||
|
||
pipeline.run.assert_called_once()
|
||
run_with_lock.assert_called_once()
|
||
call_args = run_with_lock.call_args
|
||
self.assertIs(call_args.args[1], run_market_review)
|
||
self.assertEqual(call_args.kwargs["trigger_source"], "schedule")
|
||
|
||
def test_market_review_mode_uses_shared_runtime_assembly(self) -> None:
|
||
args = self._make_args(market_review=True)
|
||
config = self._make_config(
|
||
trading_day_check_enabled=True,
|
||
market_review_region="both",
|
||
market_review_enabled=False,
|
||
database_path=str(Path(self.temp_dir.name) / "stock_analysis.db"),
|
||
)
|
||
runtime_notifier = MagicMock()
|
||
runtime_analyzer = MagicMock()
|
||
runtime_search_service = MagicMock()
|
||
|
||
with patch("main.parse_arguments", return_value=args), \
|
||
patch("main.get_config", return_value=config), \
|
||
patch("main.setup_logging"), \
|
||
patch("main._run_market_review_with_shared_lock") as run_with_lock, \
|
||
patch(
|
||
"src.core.market_review_runtime.build_market_review_runtime",
|
||
return_value=(
|
||
runtime_notifier,
|
||
runtime_analyzer,
|
||
runtime_search_service,
|
||
),
|
||
) as runtime_builder, \
|
||
patch("src.core.market_review.run_market_review") as run_market_review, \
|
||
patch("src.core.trading_calendar.get_open_markets_today", return_value={"cn", "us"}), \
|
||
patch("src.core.trading_calendar.compute_effective_region", return_value="cn,us"):
|
||
exit_code = main.main()
|
||
|
||
self.assertEqual(exit_code, 0)
|
||
runtime_builder.assert_called_once_with(config)
|
||
run_with_lock.assert_called_once()
|
||
call_args = run_with_lock.call_args
|
||
self.assertEqual(call_args.args[0], config)
|
||
self.assertIs(call_args.args[1], run_market_review)
|
||
self.assertIs(call_args.kwargs["notifier"], runtime_notifier)
|
||
self.assertIs(call_args.kwargs["analyzer"], runtime_analyzer)
|
||
self.assertIs(call_args.kwargs["search_service"], runtime_search_service)
|
||
self.assertTrue(call_args.kwargs["send_notification"])
|
||
self.assertNotIn("merge_notification", call_args.kwargs)
|
||
self.assertEqual(call_args.kwargs["override_region"], "cn,us")
|
||
self.assertEqual(call_args.kwargs["trigger_source"], "cli")
|
||
|
||
def test_market_review_mode_returns_nonzero_when_no_report_is_generated(self) -> None:
|
||
args = self._make_args(market_review=True)
|
||
config = self._make_config(
|
||
trading_day_check_enabled=True,
|
||
market_review_region="both",
|
||
market_review_enabled=False,
|
||
database_path=str(Path(self.temp_dir.name) / "stock_analysis.db"),
|
||
)
|
||
|
||
with patch("main.parse_arguments", return_value=args), \
|
||
patch("main.get_config", return_value=config), \
|
||
patch("main.setup_logging"), \
|
||
patch(
|
||
"src.core.market_review_runtime.build_market_review_runtime",
|
||
return_value=(MagicMock(), MagicMock(), MagicMock()),
|
||
), \
|
||
patch("main._run_market_review_with_shared_lock", return_value=None) as run_with_lock, \
|
||
patch("src.core.market_review.run_market_review"), \
|
||
patch("src.core.trading_calendar.get_open_markets_today", return_value={"cn", "us"}), \
|
||
patch("src.core.trading_calendar.compute_effective_region", return_value="cn,us"):
|
||
exit_code = main.main()
|
||
|
||
self.assertEqual(exit_code, 1)
|
||
run_with_lock.assert_called_once()
|
||
|
||
def test_market_review_mode_respects_comma_list_market_review_region(self) -> None:
|
||
args = self._make_args(market_review=True)
|
||
config = self._make_config(
|
||
trading_day_check_enabled=True,
|
||
market_review_region="jp,kr",
|
||
market_review_enabled=False,
|
||
database_path=str(Path(self.temp_dir.name) / "stock_analysis.db"),
|
||
)
|
||
runtime_notifier = MagicMock()
|
||
runtime_analyzer = MagicMock()
|
||
runtime_search_service = MagicMock()
|
||
|
||
with patch("main.parse_arguments", return_value=args), \
|
||
patch("main.get_config", return_value=config), \
|
||
patch("main.setup_logging"), \
|
||
patch("main._run_market_review_with_shared_lock") as run_with_lock, \
|
||
patch(
|
||
"src.core.market_review_runtime.build_market_review_runtime",
|
||
return_value=(runtime_notifier, runtime_analyzer, runtime_search_service),
|
||
) as runtime_builder, \
|
||
patch("src.core.market_review.run_market_review"), \
|
||
patch("src.core.trading_calendar.get_open_markets_today", return_value={"jp", "kr"}):
|
||
exit_code = main.main()
|
||
|
||
self.assertEqual(exit_code, 0)
|
||
runtime_builder.assert_called_once_with(config)
|
||
call_args = run_with_lock.call_args
|
||
self.assertIs(call_args.args[0], config)
|
||
self.assertEqual(call_args.kwargs["override_region"], "jp,kr")
|
||
self.assertEqual(call_args.kwargs["trigger_source"], "cli")
|
||
|
||
def test_bootstrap_logging_persists_when_config_load_fails(self) -> None:
|
||
"""Config load failure must be logged to stderr and return exit code 1.
|
||
|
||
Bootstrap logging is stderr-only so healthy runs never write to a
|
||
hard-coded directory. The error is still captured by process runners
|
||
(e.g. GitHub Actions) that collect stderr output.
|
||
"""
|
||
import io
|
||
|
||
args = self._make_args()
|
||
|
||
capture_stream = io.StringIO()
|
||
capture_handler = logging.StreamHandler(capture_stream)
|
||
capture_handler.setLevel(logging.DEBUG)
|
||
capture_handler.setFormatter(logging.Formatter("%(message)s"))
|
||
|
||
root_logger = logging.getLogger()
|
||
|
||
with patch("main.parse_arguments", return_value=args), \
|
||
patch("main.get_config", side_effect=RuntimeError("config boom")):
|
||
root_logger.addHandler(capture_handler)
|
||
try:
|
||
exit_code = main.main()
|
||
finally:
|
||
root_logger.removeHandler(capture_handler)
|
||
capture_handler.close()
|
||
|
||
self.assertEqual(exit_code, 1)
|
||
output = capture_stream.getvalue()
|
||
self.assertIn("加载配置失败", output)
|
||
self.assertIn("config boom", output)
|
||
|
||
def test_bootstrap_logging_failure_does_not_block_startup(self) -> None:
|
||
"""Bootstrap log dir unwritable must not prevent startup (P1 regression)."""
|
||
args = self._make_args()
|
||
config = self._make_config()
|
||
|
||
with patch("main.parse_arguments", return_value=args), \
|
||
patch("main.get_config", return_value=config), \
|
||
patch("main._setup_bootstrap_logging", side_effect=OSError("read-only fs")), \
|
||
patch("main.setup_logging"), \
|
||
patch("main.run_full_analysis") as run_mock:
|
||
exit_code = main.main()
|
||
|
||
self.assertEqual(exit_code, 0)
|
||
run_mock.assert_called_once()
|
||
|
||
def test_runtime_file_logging_permission_error_falls_back_to_console(self) -> None:
|
||
"""Configured file logging failures should not prevent Docker startup."""
|
||
import io
|
||
|
||
args = self._make_args()
|
||
config = self._make_config(log_dir="/app/logs")
|
||
capture_stream = io.StringIO()
|
||
capture_handler = logging.StreamHandler(capture_stream)
|
||
capture_handler.setLevel(logging.DEBUG)
|
||
capture_handler.setFormatter(logging.Formatter("%(message)s"))
|
||
|
||
root_logger = logging.getLogger()
|
||
|
||
with patch("main.parse_arguments", return_value=args), \
|
||
patch("main.get_config", return_value=config), \
|
||
patch(
|
||
"main.setup_logging",
|
||
side_effect=PermissionError("/app/logs/stock_analysis_20260511.log"),
|
||
), \
|
||
patch("main.run_full_analysis") as run_mock:
|
||
root_logger.addHandler(capture_handler)
|
||
try:
|
||
exit_code = main.main()
|
||
finally:
|
||
root_logger.removeHandler(capture_handler)
|
||
capture_handler.close()
|
||
|
||
self.assertEqual(exit_code, 0)
|
||
run_mock.assert_called_once()
|
||
output = capture_stream.getvalue()
|
||
self.assertIn("文件日志初始化失败,已降级为控制台日志输出", output)
|
||
self.assertIn("/app/logs", output)
|
||
self.assertIn("官方 Docker 镜像启动入口会自动修复默认挂载目录权限", output)
|
||
|
||
def test_run_full_analysis_import_failure_propagates(self) -> None:
|
||
"""P1: import failures in run_full_analysis must propagate, not be swallowed."""
|
||
args = self._make_args()
|
||
config = self._make_config()
|
||
|
||
with patch("main.parse_arguments", return_value=args), \
|
||
patch("main.get_config", return_value=config), \
|
||
patch("main.setup_logging"), \
|
||
patch.dict("sys.modules", {"src.core.pipeline": None}):
|
||
exit_code = main.main()
|
||
|
||
self.assertEqual(exit_code, 1)
|
||
|
||
def test_lazy_pipeline_triggers_env_bootstrap(self) -> None:
|
||
"""P2: lazy StockAnalysisPipeline access must call _bootstrap_environment."""
|
||
# Reset the lazy descriptor cache so __get__ fires again
|
||
main._LazyPipelineDescriptor._resolved = None
|
||
main._env_bootstrapped = False
|
||
|
||
with patch("main._bootstrap_environment", wraps=main._bootstrap_environment) as mock_boot, \
|
||
patch("src.core.pipeline.StockAnalysisPipeline", create=True, new_callable=lambda: type("FakePipeline", (), {})):
|
||
try:
|
||
_ = main.StockAnalysisPipeline
|
||
except Exception:
|
||
pass
|
||
mock_boot.assert_called()
|
||
|
||
# Cleanup: reset state
|
||
main._LazyPipelineDescriptor._resolved = None
|
||
main._env_bootstrapped = False
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|