Files
daily_stock_analysis/tests/test_bot_dispatcher_async.py
LouisHong 8816421fbf feat: [issue #1199 PR1] add settings field help dialog infrastructure (#1204)
* feat: add settings field help infrastructure

* fix: avoid online fallback in bot name routing test

Resolve natural-language stock-name candidates through deterministic local partial matches before invoking the broader name resolver. This keeps common aliases like 茅台 on the fast local path and prevents offline CI from waiting on AkShare network fallback.

Guard the async dispatcher test with an assertion that AkShare fallback is not called for the local alias case.

* test: isolate schedule time provider failure case

The schedule-time provider failure test could fail when SCHEDULE_TIME was present in the process environment before importing main. In that case _INITIAL_PROCESS_ENV marks it as an explicit override, the provider returns the env value, and ConfigManager.read_config_map is never called, so the expected RuntimeError is not raised.

Patch _INITIAL_PROCESS_ENV in the test to model the intended no-process-override scenario and keep the assertion independent of the shell environment used by scripts/ci_gate.sh.

* fix: improve settings help dialog accessibility

Trap keyboard focus inside the settings help dialog while it is open and return focus to the trigger on close.

Keep the backdrop click target out of the tab order and cover the focus loop behavior in the settings field test.

* feat: add help entry and multilingual support for system settings page

* feat: add maintenance guidelines for settings help documentation

* fix: clarify WebUI bind settings and toast visibility

* chore: remove trailing blank line from settings help
2026-05-08 20:32:26 +08:00

314 lines
12 KiB
Python

# -*- coding: utf-8 -*-
"""Tests for async-friendly bot dispatcher execution."""
import sys
import unittest
from datetime import datetime
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
# Keep tests runnable when optional deps are missing.
try:
import litellm # noqa: F401
except ModuleNotFoundError:
from tests.litellm_stub import ensure_litellm_stub
ensure_litellm_stub()
from bot.commands.base import BotCommand
from bot.dispatcher import CommandDispatcher
from bot.models import BotMessage, BotResponse, ChatType
class DummyCommand(BotCommand):
@property
def name(self) -> str:
return "dummy"
@property
def aliases(self):
return []
@property
def description(self) -> str:
return "dummy command"
@property
def usage(self) -> str:
return "/dummy"
def execute(self, message: BotMessage, args: list[str]) -> BotResponse:
return BotResponse.text_response("dummy-ok")
def _make_message(content: str, mentioned: bool = False) -> BotMessage:
return BotMessage(
platform="feishu",
message_id="m1",
user_id="u1",
user_name="tester",
chat_id="c1",
chat_type=ChatType.PRIVATE,
content=content,
raw_content=content,
mentioned=mentioned,
timestamp=datetime.now(),
)
class TestBotCommandAsync(unittest.IsolatedAsyncioTestCase):
async def test_execute_async_uses_to_thread(self):
cmd = DummyCommand()
message = _make_message("/dummy")
with patch(
"bot.commands.base.asyncio.to_thread",
new=AsyncMock(return_value=BotResponse.text_response("ok")),
) as to_thread:
result = await cmd.execute_async(message, [])
self.assertEqual(result.text, "ok")
to_thread.assert_awaited_once()
class TestCommandDispatcherAsync(unittest.IsolatedAsyncioTestCase):
def test_nl_prefilter_matches_bse_codes(self):
self.assertIsNotNone(CommandDispatcher._NL_PREFILTER.search("帮我分析430001"))
def test_nl_prefilter_accepts_bare_lowercase_us_ticker(self):
self.assertTrue(CommandDispatcher._passes_nl_prefilter("tsla"))
def test_nl_prefilter_rejects_common_lowercase_word(self):
self.assertFalse(CommandDispatcher._passes_nl_prefilter("hello"))
async def test_dispatch_async_awaits_command_execute_async(self):
dispatcher = CommandDispatcher()
command = DummyCommand()
command.execute_async = AsyncMock(return_value=BotResponse.text_response("async-ok"))
dispatcher.register(command)
result = await dispatcher.dispatch_async(_make_message("/dummy"))
self.assertEqual(result.text, "async-ok")
command.execute_async.assert_awaited_once()
async def test_parse_intent_via_llm_offloads_to_thread(self):
fake_response = SimpleNamespace(
content='{"intent":"analysis","codes":["600519"],"strategy":null}',
provider="gemini",
usage={"total_tokens": 12},
)
config = SimpleNamespace(litellm_model="gemini/test-model")
with patch(
"bot.dispatcher.asyncio.to_thread",
new=AsyncMock(side_effect=lambda func, *args, **kwargs: func(*args, **kwargs)),
) as to_thread:
with patch("src.agent.llm_adapter.LLMToolAdapter") as adapter_cls:
adapter = MagicMock()
adapter.call_text.return_value = fake_response
adapter_cls.return_value = adapter
result = await CommandDispatcher._parse_intent_via_llm("分析600519", config)
self.assertEqual(result["intent"], "analysis")
self.assertEqual(result["codes"], ["600519"])
to_thread.assert_awaited_once()
adapter.call_text.assert_called_once()
async def test_try_nl_routing_uses_async_command_execution(self):
dispatcher = CommandDispatcher()
ask_command = DummyCommand()
ask_command.execute_async = AsyncMock(return_value=BotResponse.text_response("ask-ok"))
dispatcher.register(ask_command)
dispatcher._commands["ask"] = ask_command
config = SimpleNamespace(
agent_nl_routing=True,
agent_mode=True,
litellm_model="gemini/test-model",
)
with patch("src.config.get_config", return_value=config):
with patch.object(dispatcher, "_parse_intent_via_llm", new=AsyncMock(return_value={
"intent": "analysis",
"codes": ["600519"],
"strategy": "缠论",
})):
result = await dispatcher._try_nl_routing(_make_message("帮我分析600519", mentioned=True))
self.assertIsNotNone(result)
self.assertEqual(result.text, "ask-ok")
ask_command.execute_async.assert_awaited_once()
async def test_try_nl_routing_resolves_name_only_analysis_request(self):
dispatcher = CommandDispatcher()
ask_command = DummyCommand()
ask_command.execute_async = AsyncMock(return_value=BotResponse.text_response("ask-ok"))
dispatcher.register(ask_command)
dispatcher._commands["ask"] = ask_command
config = SimpleNamespace(
agent_nl_routing=True,
agent_mode=True,
litellm_model="gemini/test-model",
)
with patch("src.config.get_config", return_value=config):
with patch(
"src.services.name_to_code_resolver._get_akshare_name_to_code"
) as mock_akshare:
with patch.object(
dispatcher,
"_parse_intent_via_llm",
new=AsyncMock(return_value={
"intent": "analysis",
"codes": [],
"strategy": None,
}),
):
result = await dispatcher._try_nl_routing(_make_message("帮我分析茅台", mentioned=True))
self.assertIsNotNone(result)
self.assertEqual(result.text, "ask-ok")
ask_command.execute_async.assert_awaited_once()
mock_akshare.assert_not_called()
_, args = ask_command.execute_async.await_args.args
self.assertEqual(args, ["600519"])
class TestCommandDispatcherSyncCompatibility(unittest.TestCase):
def test_dispatch_sync_wrapper_still_works(self):
dispatcher = CommandDispatcher()
dispatcher.register(DummyCommand())
result = dispatcher.dispatch(_make_message("/dummy"))
self.assertEqual(result.text, "dummy-ok")
def test_dispatch_sync_path_does_not_require_execute_async(self):
dispatcher = CommandDispatcher()
class SyncOnlyCommand(DummyCommand):
async def execute_async(self, message, args): # pragma: no cover - must not be called
raise AssertionError("sync dispatch should call execute() directly")
dispatcher.register(SyncOnlyCommand())
result = dispatcher.dispatch(_make_message("/dummy"))
self.assertEqual(result.text, "dummy-ok")
class TestHandleWebhookAsync(unittest.IsolatedAsyncioTestCase):
"""Test the async webhook handler path."""
async def test_handle_webhook_async_dispatches_via_async(self):
from bot.handler import handle_webhook_async
fake_platform = MagicMock()
fake_message = _make_message("/dummy")
fake_platform.handle_webhook.return_value = (fake_message, None)
fake_platform.format_response.return_value = MagicMock(text="ok-response")
fake_config = MagicMock()
fake_config.bot_enabled = True
with patch("src.config.get_config", return_value=fake_config), \
patch("bot.handler.get_platform", return_value=fake_platform), \
patch("bot.handler.get_dispatcher") as mock_get_disp:
mock_dispatcher = MagicMock()
mock_dispatcher.dispatch_async = AsyncMock(return_value=BotResponse.text_response("async-resp"))
mock_get_disp.return_value = mock_dispatcher
await handle_webhook_async("feishu", {}, b'{}')
mock_dispatcher.dispatch_async.assert_awaited_once()
async def test_handle_webhook_async_returns_success_when_bot_disabled(self):
from bot.handler import handle_webhook_async
fake_config = MagicMock()
fake_config.bot_enabled = False
with patch("src.config.get_config", return_value=fake_config):
result = await handle_webhook_async("feishu", {}, b'{}')
# WebhookResponse.success() returns status_code 200
self.assertEqual(result.status_code, 200)
class TestChatCommandCompatibility(unittest.TestCase):
def test_chat_command_reuses_legacy_session_id_when_history_exists(self):
from bot.commands.chat import ChatCommand
command = ChatCommand()
config = SimpleNamespace(agent_mode=True)
executor = MagicMock()
executor.chat.return_value = SimpleNamespace(success=True, content="ok", error=None)
db = MagicMock()
db.conversation_session_exists.side_effect = lambda session_id: session_id == "feishu_u1"
with patch("bot.commands.chat.get_config", return_value=config), \
patch("src.storage.get_db", return_value=db), \
patch("src.agent.factory.build_agent_executor", return_value=executor):
response = command.execute(_make_message("/chat hello"), ["hello"])
self.assertEqual(response.text, "ok")
executor.chat.assert_called_once()
self.assertEqual(executor.chat.call_args.kwargs["session_id"], "feishu_u1")
def test_chat_command_scopes_group_session_by_chat_id(self):
from bot.commands.chat import ChatCommand
command = ChatCommand()
config = SimpleNamespace(agent_mode=True)
executor = MagicMock()
executor.chat.return_value = SimpleNamespace(success=True, content="ok", error=None)
db = MagicMock()
db.conversation_session_exists.side_effect = lambda session_id: session_id == "feishu_u1"
message = _make_message("/chat hello")
message.chat_type = ChatType.GROUP
message.chat_id = "group-1"
with patch("bot.commands.chat.get_config", return_value=config), \
patch("src.storage.get_db", return_value=db), \
patch("src.agent.factory.build_agent_executor", return_value=executor):
response = command.execute(message, ["hello"])
self.assertEqual(response.text, "ok")
executor.chat.assert_called_once()
self.assertEqual(executor.chat.call_args.kwargs["session_id"], "feishu_u1:group-1:chat")
class TestHistoryCommandCompatibility(unittest.TestCase):
def test_history_clear_uses_group_scoped_session(self):
from bot.commands.history import HistoryCommand
command = HistoryCommand()
db = MagicMock()
db.delete_conversation_session.side_effect = lambda session_id: 1 if session_id == "feishu_u1:group-1:chat" else 0
message = _make_message("/history clear")
message.chat_type = ChatType.GROUP
message.chat_id = "group-1"
with patch("src.storage.get_db", return_value=db):
response = command.execute(message, ["clear"])
self.assertIn("1 条消息", response.text)
db.delete_conversation_session.assert_called_once_with("feishu_u1:group-1:chat")
class TestDispatcherBaseException(unittest.TestCase):
"""Verify dispatcher thread propagates BaseException subclasses."""
def test_error_holder_accepts_base_exception(self):
"""Ensure error_holder dict uses BaseException type hint (code review)."""
import inspect
from bot.dispatcher import CommandDispatcher
source = inspect.getsource(CommandDispatcher.dispatch)
self.assertIn("BaseException", source)
self.assertNotIn("except Exception", source)
if __name__ == "__main__":
unittest.main()