Files
MoviePilot/tests/test_interaction_router.py
T
jxxghp dd38c16400 refactor(messaging): 拆分用户交互模块到 application/messaging 层
- 新增 application/messaging 交互层:router.py 统一会话优先级与回调分发,
  site/subscribe/skill/media/plugin 各交互状态与视图从 Chain 迁出
- MessageChain 改为通过 InteractionRouter 派发文本会话与按钮回调,
  新增结构化 callback_data 通道(兼容 CALLBACK: 文本前缀)
- Transfer 失败重试/AI 接管回调归入 TransferChain
- MediaInteractionChain 拆出为 app/chain/interaction.py(旧路径保留兼容别名)
- WebAgent Endpoint 去重,统一使用 agent.py 回调协议函数
- 删除 app/chain/skills.py(交互逻辑并入 SkillInteractionHandler)
- 同步更新架构文档与测试,全量 4476 通过
2026-08-15 16:36:39 +08:00

194 lines
7.2 KiB
Python

"""InteractionRouter 单元测试:会话选择、回调派发顺序和未消费回退语义。"""
import unittest
from datetime import datetime, timedelta
from types import SimpleNamespace
from unittest.mock import MagicMock
from app.testing.bootstrap import ensure_optional_stub
ensure_optional_stub("qbittorrentapi", TorrentFilesList=list)
ensure_optional_stub("transmission_rpc", File=object)
ensure_optional_stub("psutil")
ensure_optional_stub("aioshutil")
ensure_optional_stub("pyquery", PyQuery=object)
from app.application.messaging.interaction import InteractionContext, InteractionDispatch
from app.application.messaging.router import (
CallbackRoute,
InteractionRouter,
SessionRoute,
has_pending_interaction,
)
from app.application.messaging.site import site_interaction_manager
from app.application.messaging.skill import skill_interaction_manager
from app.schemas.types import MessageChannel
def _context(user_id="10001") -> InteractionContext:
"""构造最小交互上下文。"""
return InteractionContext(
channel=MessageChannel.Telegram,
source="telegram-test",
user_id=user_id,
username="tester",
)
def _session_route(name: str, pending=None, consumed=True) -> tuple[SessionRoute, MagicMock]:
"""构造带可控返回值的会话路由,同时返回 handler 便于断言。"""
handler = MagicMock(return_value=consumed)
route = SessionRoute(
name=name,
get_pending=lambda _user_id, _pending=pending: _pending,
handle_text=handler,
)
return route, handler
def _callback_route(name: str, matched=True, handled=True) -> CallbackRoute:
"""构造带可控匹配和处理结果的回调路由。"""
dispatcher = MagicMock(return_value=InteractionDispatch(handled=handled))
return CallbackRoute(
name=name,
matches=lambda _data, _matched=matched: _matched,
dispatch=dispatcher,
)
class TestInteractionRouterSessions(unittest.TestCase):
def test_latest_session_prefers_newest_created_at(self):
"""多个待处理会话时选择创建时间最近的一条。"""
now = datetime.now()
old_route, _ = _session_route(
"sites", pending=SimpleNamespace(created_at=now - timedelta(minutes=10))
)
new_route, _ = _session_route(
"media", pending=SimpleNamespace(created_at=now)
)
router = InteractionRouter(
session_routes=[old_route, new_route], callback_routes=[]
)
self.assertEqual(router.latest_session("10001"), new_route)
def test_latest_session_missing_timestamp_treated_as_oldest(self):
"""缺少时间戳的会话不应抢占有时间戳的会话。"""
plain_route, _ = _session_route("sites", pending=SimpleNamespace())
stamped_route, _ = _session_route(
"media", pending=SimpleNamespace(created_at=datetime.now())
)
router = InteractionRouter(
session_routes=[plain_route, stamped_route], callback_routes=[]
)
self.assertEqual(router.latest_session("10001"), stamped_route)
def test_dispatch_active_text_consumed_by_latest_session(self):
"""文本应只派发给最近会话并返回其消费结果。"""
old_route, old_handler = _session_route(
"sites", pending=SimpleNamespace(created_at=None)
)
new_route, new_handler = _session_route(
"media", pending=SimpleNamespace(created_at=datetime.now())
)
router = InteractionRouter(
session_routes=[old_route, new_route], callback_routes=[]
)
self.assertTrue(router.dispatch_active_text(_context(), "输入内容"))
new_handler.assert_called_once()
old_handler.assert_not_called()
def test_dispatch_active_text_returns_false_without_session(self):
"""没有待处理会话时不消费文本。"""
router = InteractionRouter(
session_routes=[_session_route("sites", pending=None)[0]], callback_routes=[]
)
self.assertFalse(router.dispatch_active_text(_context(), "输入内容"))
def test_has_pending_checks_all_routes(self):
"""任意路由存在待处理会话即视为有待处理交互。"""
router = InteractionRouter(
session_routes=[
_session_route("sites", pending=None)[0],
_session_route("media", pending=SimpleNamespace())[0],
],
callback_routes=[],
)
self.assertTrue(router.has_pending("10001"))
empty_router = InteractionRouter(
session_routes=[_session_route("sites", pending=None)[0]], callback_routes=[]
)
self.assertFalse(empty_router.has_pending("10001"))
class TestInteractionRouterCallbacks(unittest.TestCase):
def test_dispatch_callback_respects_registration_order(self):
"""回调按注册顺序匹配,首个匹配并消费的路由生效。"""
first = _callback_route("transfer", matched=True, handled=True)
second = _callback_route("skill", matched=True, handled=True)
router = InteractionRouter(
session_routes=[], callback_routes=[first, second]
)
result = router.dispatch_callback(_context(), "any")
self.assertTrue(result.handled)
first.dispatch.assert_called_once()
second.dispatch.assert_not_called()
def test_dispatch_callback_continues_when_matched_route_not_handled(self):
"""匹配但未消费的路由不拦截后续路由。"""
unmatched = _callback_route("transfer", matched=False, handled=True)
skipped = _callback_route("skill", matched=True, handled=False)
consumer = _callback_route("site", matched=True, handled=True)
router = InteractionRouter(
session_routes=[], callback_routes=[unmatched, skipped, consumer]
)
result = router.dispatch_callback(_context(), "any")
self.assertTrue(result.handled)
unmatched.dispatch.assert_not_called()
skipped.dispatch.assert_called_once()
consumer.dispatch.assert_called_once()
def test_dispatch_callback_unhandled_when_no_route_matches(self):
"""所有路由均不匹配时返回未处理。"""
router = InteractionRouter(
session_routes=[],
callback_routes=[_callback_route("transfer", matched=False)],
)
result = router.dispatch_callback(_context(), "unknown")
self.assertFalse(result.handled)
self.assertFalse(result.defer_processing_finish)
class TestHasPendingInteraction(unittest.TestCase):
def tearDown(self):
site_interaction_manager.clear()
skill_interaction_manager.clear()
def test_has_pending_interaction_detects_real_sessions(self):
"""WebAgent 判断应覆盖真实交互会话管理器。"""
self.assertFalse(has_pending_interaction("10001"))
site_interaction_manager.create_or_replace(
user_id="10001",
command="/sites",
channel=MessageChannel.Telegram,
source="telegram-test",
username="tester",
)
self.assertTrue(has_pending_interaction("10001"))
self.assertFalse(has_pending_interaction("10002"))
if __name__ == "__main__":
unittest.main()