feat: 架构修复 - P0/P1 问题全面修复
P0 修复: - runtimeStore: 添加缺失的 lastDayHistory 字段 - Gateway/RuntimeService: 状态同步改为内存优先,消除 glob 竞态 - App.jsx: 从 3075 行重构到 ~500 行,提取 8 个独立文件 P1 修复: - CORS: 4 个服务改为从环境变量读取允许 origins - MarketStore: 改为模块级单例模式 - Domain 层: 删除 trading thin wrapper,保留 news 真实逻辑 - 测试: 补齐 77 个 gateway/runtime 测试 新增文件: - backend/tests/test_gateway.py (43 tests) - frontend/src/hooks/useWebSocketHandler.js - frontend/src/hooks/useStockRequestCallbacks.js - frontend/src/hooks/useAgentCallbacks.js - frontend/src/hooks/useRuntimeCallbacks.js - frontend/src/hooks/useWatchlistCallbacks.js - frontend/src/components/TickerBar.jsx - frontend/src/components/HeaderRight.jsx - frontend/src/components/ChartTabs.jsx Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,14 +1,31 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Tests for the extracted runtime service app surface."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from backend.api import runtime as runtime_module
|
||||
from backend.apps.runtime_service import create_app
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_runtime_module_state():
|
||||
"""Reset module-level runtime_manager before each test."""
|
||||
runtime_module.runtime_manager = None
|
||||
# Also reset RuntimeState singleton's _runtime_manager
|
||||
rs = runtime_module.get_runtime_state()
|
||||
rs._runtime_manager = None
|
||||
yield
|
||||
runtime_module.runtime_manager = None
|
||||
rs = runtime_module.get_runtime_state()
|
||||
rs._runtime_manager = None
|
||||
|
||||
|
||||
def test_runtime_service_routes_are_exposed():
|
||||
app = create_app()
|
||||
paths = {route.path for route in app.routes}
|
||||
@@ -153,7 +170,9 @@ def test_runtime_service_update_runtime_config_persists_bootstrap(monkeypatch, t
|
||||
)
|
||||
|
||||
class _DummyContext:
|
||||
def __init__(self):
|
||||
def __init__(self, run_dir):
|
||||
self.config_name = "demo"
|
||||
self.run_dir = run_dir
|
||||
self.bootstrap_values = {
|
||||
"tickers": ["AAPL"],
|
||||
"schedule_mode": "daily",
|
||||
@@ -165,8 +184,17 @@ def test_runtime_service_update_runtime_config_persists_bootstrap(monkeypatch, t
|
||||
class _DummyManager:
|
||||
def __init__(self):
|
||||
self.config_name = "demo"
|
||||
self.bootstrap = dict(_DummyContext().bootstrap_values)
|
||||
self.context = _DummyContext()
|
||||
self.bootstrap = dict(_DummyContext(run_dir).bootstrap_values)
|
||||
self.context = _DummyContext(run_dir)
|
||||
|
||||
def build_snapshot(self):
|
||||
return {
|
||||
"context": {
|
||||
"config_name": self.context.config_name,
|
||||
"run_dir": str(self.context.run_dir),
|
||||
"bootstrap_values": self.context.bootstrap_values,
|
||||
}
|
||||
}
|
||||
|
||||
def _persist_snapshot(self):
|
||||
return None
|
||||
@@ -192,3 +220,385 @@ def test_runtime_service_update_runtime_config_persists_bootstrap(monkeypatch, t
|
||||
assert payload["bootstrap"]["schedule_mode"] == "intraday"
|
||||
assert payload["resolved"]["interval_minutes"] == 15
|
||||
assert "interval_minutes: 15" in (run_dir / "BOOTSTRAP.md").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# RuntimeState singleton unit tests
|
||||
# =============================================================================
|
||||
|
||||
def test_runtime_state_is_singleton():
|
||||
"""RuntimeState.__new__ returns the same instance across calls."""
|
||||
state1 = runtime_module.RuntimeState()
|
||||
state2 = runtime_module.RuntimeState()
|
||||
assert state1 is state2
|
||||
|
||||
|
||||
def test_runtime_state_get_runtime_state_returns_same_instance():
|
||||
"""get_runtime_state() returns the module singleton."""
|
||||
instance = runtime_module.get_runtime_state()
|
||||
assert instance is runtime_module._runtime_state
|
||||
|
||||
|
||||
def test_runtime_state_default_values():
|
||||
"""RuntimeState initializes with sensible defaults on first instantiation."""
|
||||
# Reset singleton to get fresh __init__ values
|
||||
runtime_module.RuntimeState._instance = None
|
||||
runtime_module.RuntimeState._lock = asyncio.Lock()
|
||||
state = runtime_module.RuntimeState()
|
||||
assert state._runtime_manager is None
|
||||
assert state._gateway_process is None
|
||||
assert state._gateway_port == 8765
|
||||
|
||||
|
||||
def test_runtime_state_gateway_port_property():
|
||||
"""gateway_port property getter and setter work correctly."""
|
||||
runtime_module.RuntimeState._instance = None
|
||||
runtime_module.RuntimeState._lock = asyncio.Lock()
|
||||
state = runtime_module.RuntimeState()
|
||||
state.gateway_port = 9999
|
||||
assert state.gateway_port == 9999
|
||||
state.gateway_port = 1234
|
||||
assert state.gateway_port == 1234
|
||||
|
||||
|
||||
def test_runtime_state_gateway_process_property():
|
||||
"""gateway_process property getter and setter work correctly."""
|
||||
runtime_module.RuntimeState._instance = None
|
||||
runtime_module.RuntimeState._lock = asyncio.Lock()
|
||||
state = runtime_module.RuntimeState()
|
||||
assert state.gateway_process is None
|
||||
|
||||
fake_process = object()
|
||||
state.gateway_process = fake_process
|
||||
assert state.gateway_process is fake_process
|
||||
|
||||
state.gateway_process = None
|
||||
assert state.gateway_process is None
|
||||
|
||||
|
||||
def test_runtime_state_runtime_manager_property():
|
||||
"""runtime_manager property getter and setter work correctly."""
|
||||
runtime_module.RuntimeState._instance = None
|
||||
runtime_module.RuntimeState._lock = asyncio.Lock()
|
||||
state = runtime_module.RuntimeState()
|
||||
assert state.runtime_manager is None
|
||||
|
||||
fake_manager = object()
|
||||
state.runtime_manager = fake_manager
|
||||
assert state.runtime_manager is fake_manager
|
||||
|
||||
state.runtime_manager = None
|
||||
assert state.runtime_manager is None
|
||||
|
||||
|
||||
@pytest.mark.filterwarnings("ignore::RuntimeWarning")
|
||||
def test_runtime_state_lock_property_is_async():
|
||||
"""lock is an async property that returns a coroutine producing an asyncio.Lock."""
|
||||
runtime_module.RuntimeState._instance = None
|
||||
runtime_module.RuntimeState._lock = asyncio.Lock()
|
||||
state = runtime_module.RuntimeState()
|
||||
lock_coro = state.lock
|
||||
assert asyncio.iscoroutine(lock_coro)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runtime_state_async_set_get_gateway_port():
|
||||
"""Async setters and getters for gateway_port with lock protection."""
|
||||
runtime_module.RuntimeState._instance = None
|
||||
runtime_module.RuntimeState._lock = asyncio.Lock()
|
||||
state = runtime_module.RuntimeState()
|
||||
await state.set_gateway_port(8888)
|
||||
assert await state.get_gateway_port() == 8888
|
||||
await state.set_gateway_port(7777)
|
||||
assert await state.get_gateway_port() == 7777
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runtime_state_async_set_get_gateway_process():
|
||||
"""Async setters and getters for gateway_process with lock protection."""
|
||||
runtime_module.RuntimeState._instance = None
|
||||
runtime_module.RuntimeState._lock = asyncio.Lock()
|
||||
state = runtime_module.RuntimeState()
|
||||
await state.set_gateway_process(None)
|
||||
assert await state.get_gateway_process() is None
|
||||
|
||||
fake_process = object()
|
||||
await state.set_gateway_process(fake_process)
|
||||
assert await state.get_gateway_process() is fake_process
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runtime_state_async_set_get_runtime_manager():
|
||||
"""Async setters and getters for runtime_manager with lock protection."""
|
||||
runtime_module.RuntimeState._instance = None
|
||||
runtime_module.RuntimeState._lock = asyncio.Lock()
|
||||
state = runtime_module.RuntimeState()
|
||||
await state.set_runtime_manager(None)
|
||||
assert await state.get_runtime_manager() is None
|
||||
|
||||
fake_manager = object()
|
||||
await state.set_runtime_manager(fake_manager)
|
||||
assert await state.get_runtime_manager() is fake_manager
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# _is_gateway_running helper tests
|
||||
# =============================================================================
|
||||
|
||||
def test_is_gateway_running_returns_false_when_process_is_none():
|
||||
"""_is_gateway_running returns False when gateway_process is None."""
|
||||
runtime_module.RuntimeState._instance = None
|
||||
runtime_module.RuntimeState._lock = asyncio.Lock()
|
||||
new_state = runtime_module.RuntimeState()
|
||||
new_state._gateway_process = None
|
||||
runtime_module._runtime_state = new_state
|
||||
assert runtime_module._is_gateway_running() is False
|
||||
|
||||
|
||||
def test_is_gateway_running_returns_false_when_process_exited():
|
||||
"""_is_gateway_running returns False when process has terminated."""
|
||||
runtime_module.RuntimeState._instance = None
|
||||
runtime_module.RuntimeState._lock = asyncio.Lock()
|
||||
state = runtime_module.RuntimeState()
|
||||
runtime_module._runtime_state = state
|
||||
|
||||
mock_process = MagicMock()
|
||||
mock_process.poll.return_value = 1 # non-None = process has exited
|
||||
|
||||
state._gateway_process = mock_process
|
||||
assert runtime_module._is_gateway_running() is False
|
||||
|
||||
|
||||
def test_is_gateway_running_returns_true_when_process_running():
|
||||
"""_is_gateway_running returns True when process is alive."""
|
||||
runtime_module.RuntimeState._instance = None
|
||||
runtime_module.RuntimeState._lock = asyncio.Lock()
|
||||
state = runtime_module.RuntimeState()
|
||||
runtime_module._runtime_state = state
|
||||
|
||||
mock_process = MagicMock()
|
||||
mock_process.poll.return_value = None # None = still running
|
||||
|
||||
state._gateway_process = mock_process
|
||||
assert runtime_module._is_gateway_running() is True
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# _stop_gateway helper tests
|
||||
# =============================================================================
|
||||
|
||||
def test_stop_gateway_returns_false_when_no_process():
|
||||
"""_stop_gateway returns False if no gateway process exists."""
|
||||
runtime_module.RuntimeState._instance = None
|
||||
runtime_module.RuntimeState._lock = asyncio.Lock()
|
||||
new_state = runtime_module.RuntimeState()
|
||||
new_state._gateway_process = None
|
||||
runtime_module._runtime_state = new_state
|
||||
|
||||
result = runtime_module._stop_gateway()
|
||||
assert result is False
|
||||
|
||||
|
||||
def test_stop_gateway_sets_process_to_none_after_stop():
|
||||
"""_stop_gateway sets _gateway_process to None after stopping."""
|
||||
runtime_module.RuntimeState._instance = None
|
||||
runtime_module.RuntimeState._lock = asyncio.Lock()
|
||||
state = runtime_module.RuntimeState()
|
||||
runtime_module._runtime_state = state
|
||||
|
||||
mock_process = MagicMock()
|
||||
mock_process.poll.return_value = None
|
||||
mock_process.wait.return_value = 0
|
||||
|
||||
state._gateway_process = mock_process
|
||||
|
||||
result = runtime_module._stop_gateway()
|
||||
|
||||
assert result is True
|
||||
assert state._gateway_process is None
|
||||
mock_process.terminate.assert_called_once()
|
||||
mock_process.wait.assert_called_once()
|
||||
|
||||
|
||||
def test_stop_gateway_kills_when_terminate_times_out():
|
||||
"""_stop_gateway kills the process if terminate times out."""
|
||||
import subprocess
|
||||
runtime_module.RuntimeState._instance = None
|
||||
runtime_module.RuntimeState._lock = asyncio.Lock()
|
||||
state = runtime_module.RuntimeState()
|
||||
runtime_module._runtime_state = state
|
||||
|
||||
mock_process = MagicMock()
|
||||
mock_process.poll.return_value = None
|
||||
mock_process.wait.side_effect = subprocess.TimeoutExpired("cmd", 5)
|
||||
mock_process.kill.return_value = None
|
||||
|
||||
state._gateway_process = mock_process
|
||||
|
||||
result = runtime_module._stop_gateway()
|
||||
|
||||
assert result is True
|
||||
assert state._gateway_process is None
|
||||
mock_process.kill.assert_called_once()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# _build_gateway_ws_url helper tests
|
||||
# =============================================================================
|
||||
|
||||
def test_build_gateway_ws_url_defaults_to_ws():
|
||||
from fastapi import Request
|
||||
|
||||
mock_request = MagicMock(spec=Request)
|
||||
mock_request.headers.get.side_effect = lambda k, d="": d
|
||||
mock_request.url.scheme = "http"
|
||||
mock_request.url.hostname = "localhost"
|
||||
|
||||
url = runtime_module._build_gateway_ws_url(mock_request, 8765)
|
||||
assert url == "ws://localhost:8765"
|
||||
|
||||
|
||||
def test_build_gateway_ws_url_uses_wss_for_https():
|
||||
from fastapi import Request
|
||||
|
||||
mock_request = MagicMock(spec=Request)
|
||||
mock_request.headers.get.side_effect = lambda k, d="": d
|
||||
mock_request.url.scheme = "https"
|
||||
mock_request.url.hostname = "example.com"
|
||||
|
||||
url = runtime_module._build_gateway_ws_url(mock_request, 8765)
|
||||
assert url == "wss://example.com:8765"
|
||||
|
||||
|
||||
def test_build_gateway_ws_url_respects_forwarded_proto():
|
||||
from fastapi import Request
|
||||
|
||||
mock_request = MagicMock(spec=Request)
|
||||
def header_get(key, default=""):
|
||||
if key == "x-forwarded-proto":
|
||||
return "https"
|
||||
return default
|
||||
mock_request.headers.get.side_effect = header_get
|
||||
mock_request.url.scheme = "http"
|
||||
mock_request.url.hostname = "internal.example"
|
||||
|
||||
url = runtime_module._build_gateway_ws_url(mock_request, 8765)
|
||||
assert url == "wss://internal.example:8765"
|
||||
|
||||
|
||||
def test_build_gateway_ws_url_respects_forwarded_host():
|
||||
from fastapi import Request
|
||||
|
||||
mock_request = MagicMock(spec=Request)
|
||||
mock_request.headers.get.side_effect = lambda k, d="": {
|
||||
"x-forwarded-host": "external.example.com"
|
||||
}.get(k, d)
|
||||
mock_request.url.scheme = "http"
|
||||
mock_request.url.hostname = "internal.example"
|
||||
|
||||
url = runtime_module._build_gateway_ws_url(mock_request, 8765)
|
||||
assert url == "ws://external.example.com:8765"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# _normalize_runtime_config_updates tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def test_normalize_runtime_config_updates_validates_schedule_mode():
|
||||
req = runtime_module.UpdateRuntimeConfigRequest(schedule_mode="invalid")
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
runtime_module._normalize_runtime_config_updates(req)
|
||||
assert "schedule_mode" in str(exc_info.value.detail).lower()
|
||||
|
||||
|
||||
def test_normalize_runtime_config_updates_validates_schedule_mode_values():
|
||||
for invalid in ["weekly", "monthly", "once"]:
|
||||
req = runtime_module.UpdateRuntimeConfigRequest(schedule_mode=invalid)
|
||||
with pytest.raises(HTTPException):
|
||||
runtime_module._normalize_runtime_config_updates(req)
|
||||
|
||||
|
||||
def test_normalize_runtime_config_updates_accepts_daily_and_intraday():
|
||||
for valid in ["daily", "intraday", "DAILY", "IntraDay"]:
|
||||
req = runtime_module.UpdateRuntimeConfigRequest(schedule_mode=valid)
|
||||
result = runtime_module._normalize_runtime_config_updates(req)
|
||||
assert "schedule_mode" in result
|
||||
|
||||
|
||||
def test_normalize_runtime_config_updates_validates_trigger_time_format():
|
||||
req = runtime_module.UpdateRuntimeConfigRequest(trigger_time="25:99")
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
runtime_module._normalize_runtime_config_updates(req)
|
||||
assert "trigger_time" in str(exc_info.value.detail).lower()
|
||||
|
||||
|
||||
def test_normalize_runtime_config_updates_accepts_now_trigger_time():
|
||||
req = runtime_module.UpdateRuntimeConfigRequest(trigger_time="now")
|
||||
result = runtime_module._normalize_runtime_config_updates(req)
|
||||
assert result["trigger_time"] == "now"
|
||||
|
||||
|
||||
def test_normalize_runtime_config_updates_defaults_empty_trigger_time():
|
||||
req = runtime_module.UpdateRuntimeConfigRequest(trigger_time=" ")
|
||||
result = runtime_module._normalize_runtime_config_updates(req)
|
||||
assert result["trigger_time"] == "09:30"
|
||||
|
||||
|
||||
def test_normalize_runtime_config_updates_rejects_no_updates():
|
||||
req = runtime_module.UpdateRuntimeConfigRequest()
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
runtime_module._normalize_runtime_config_updates(req)
|
||||
assert "no runtime config updates" in str(exc_info.value.detail).lower()
|
||||
|
||||
|
||||
def test_normalize_runtime_config_updates_coerces_types():
|
||||
req = runtime_module.UpdateRuntimeConfigRequest(
|
||||
schedule_mode="intraday",
|
||||
interval_minutes="30", # string from JSON
|
||||
initial_cash="50000.0", # string from JSON
|
||||
margin_requirement="0.25",
|
||||
)
|
||||
result = runtime_module._normalize_runtime_config_updates(req)
|
||||
assert result["schedule_mode"] == "intraday"
|
||||
assert result["interval_minutes"] == 30
|
||||
assert result["initial_cash"] == 50000.0
|
||||
assert result["margin_requirement"] == 0.25
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# register_runtime_manager / unregister_runtime_manager tests
|
||||
# =============================================================================
|
||||
|
||||
def test_register_runtime_manager_sets_module_and_singleton():
|
||||
runtime_module._runtime_state._initialized = True # prevent re-init
|
||||
fake_manager = object()
|
||||
|
||||
runtime_module.register_runtime_manager(fake_manager)
|
||||
|
||||
assert runtime_module.runtime_manager is fake_manager
|
||||
assert runtime_module._runtime_state.runtime_manager is fake_manager
|
||||
|
||||
|
||||
def test_unregister_runtime_manager_clears_module_and_singleton():
|
||||
runtime_module._runtime_state._initialized = True # prevent re-init
|
||||
runtime_module._runtime_state.runtime_manager = object()
|
||||
runtime_module.runtime_manager = object()
|
||||
|
||||
runtime_module.unregister_runtime_manager()
|
||||
|
||||
assert runtime_module.runtime_manager is None
|
||||
assert runtime_module._runtime_state.runtime_manager is None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# _generate_run_id tests
|
||||
# =============================================================================
|
||||
|
||||
def test_generate_run_id_returns_timestamp_format():
|
||||
run_id = runtime_module._generate_run_id()
|
||||
# Format: YYYYMMDD_HHMMSS - length is 15
|
||||
assert len(run_id) == 15
|
||||
assert run_id[8] == "_" # separator between date and time
|
||||
assert run_id[:8].isdigit() # YYYYMMDD
|
||||
assert run_id[9:].isdigit() # HHMMSS
|
||||
|
||||
Reference in New Issue
Block a user