yuanbao platform (#16298)
Co-authored-by: loongzhao <loongzhao@tencent.com>
This commit is contained in:
416
tests/test_yuanbao_integration.py
Normal file
416
tests/test_yuanbao_integration.py
Normal file
@@ -0,0 +1,416 @@
|
||||
"""
|
||||
test_yuanbao_integration.py - Yuanbao 模块集成测试
|
||||
|
||||
验证各模块能正确组装和交互:
|
||||
- YuanbaoAdapter 初始化
|
||||
- Config / Platform 枚举
|
||||
- get_connected_platforms 逻辑
|
||||
- Proto 编解码 round-trip
|
||||
- Markdown 分块
|
||||
- API / Media 模块 import
|
||||
- Toolset 注册
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
# 确保 hermes-agent 根目录在 sys.path 中
|
||||
_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
if _REPO_ROOT not in sys.path:
|
||||
sys.path.insert(0, _REPO_ROOT)
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from gateway.config import Platform, PlatformConfig, GatewayConfig
|
||||
from gateway.platforms.yuanbao import YuanbaoAdapter
|
||||
|
||||
|
||||
def make_config(**kwargs):
|
||||
extra = kwargs.pop("extra", {})
|
||||
extra.setdefault("app_id", "test_key")
|
||||
extra.setdefault("app_secret", "test_secret")
|
||||
extra.setdefault("ws_url", "wss://test.example.com/ws")
|
||||
extra.setdefault("api_domain", "https://test.example.com")
|
||||
return PlatformConfig(
|
||||
extra=extra,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
# ===========================================================
|
||||
# 1. Adapter 初始化
|
||||
# ===========================================================
|
||||
|
||||
class TestYuanbaoAdapterInit:
|
||||
def test_create_adapter(self):
|
||||
config = make_config()
|
||||
adapter = YuanbaoAdapter(config)
|
||||
assert adapter is not None
|
||||
assert adapter.PLATFORM == Platform.YUANBAO
|
||||
|
||||
def test_initial_state(self):
|
||||
config = make_config()
|
||||
adapter = YuanbaoAdapter(config)
|
||||
status = adapter.get_status()
|
||||
assert status["connected"] == False
|
||||
assert status["bot_id"] is None
|
||||
|
||||
|
||||
# ===========================================================
|
||||
# 2. Config / Platform 枚举
|
||||
# ===========================================================
|
||||
|
||||
class TestYuanbaoConfig:
|
||||
def test_platform_enum(self):
|
||||
assert Platform.YUANBAO.value == "yuanbao"
|
||||
|
||||
def test_config_fields(self):
|
||||
config = make_config()
|
||||
assert config.extra["app_id"] == "test_key"
|
||||
assert config.extra["app_secret"] == "test_secret"
|
||||
|
||||
def test_get_connected_platforms_requires_key_and_secret(self):
|
||||
# Only key, no secret → not in connected list
|
||||
gw_only_key = GatewayConfig(
|
||||
platforms={
|
||||
Platform.YUANBAO: PlatformConfig(
|
||||
enabled=True,
|
||||
extra={"app_id": "key"},
|
||||
)
|
||||
}
|
||||
)
|
||||
platforms = gw_only_key.get_connected_platforms()
|
||||
assert Platform.YUANBAO not in platforms
|
||||
|
||||
# key + secret both present → in connected list
|
||||
gw_full = GatewayConfig(
|
||||
platforms={
|
||||
Platform.YUANBAO: PlatformConfig(
|
||||
enabled=True,
|
||||
extra={"app_id": "key", "app_secret": "secret"},
|
||||
)
|
||||
}
|
||||
)
|
||||
platforms2 = gw_full.get_connected_platforms()
|
||||
assert Platform.YUANBAO in platforms2
|
||||
|
||||
|
||||
# ===========================================================
|
||||
# 3. GatewayRunner 注册
|
||||
# ===========================================================
|
||||
|
||||
class TestGatewayRunnerRegistration:
|
||||
def test_yuanbao_in_platform_enum(self):
|
||||
"""Platform 枚举包含 YUANBAO"""
|
||||
assert hasattr(Platform, "YUANBAO")
|
||||
assert Platform.YUANBAO.value == "yuanbao"
|
||||
|
||||
def _make_minimal_runner(self, config):
|
||||
"""通过 __new__ + 最小初始化绕过 run.py 的模块级 dotenv/ssl 副作用"""
|
||||
import sys
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
# Stub out heavy dependencies if not already present
|
||||
stubs = [
|
||||
"dotenv",
|
||||
"hermes_cli.env_loader",
|
||||
"hermes_cli.config",
|
||||
"hermes_constants",
|
||||
]
|
||||
_orig = {}
|
||||
for mod in stubs:
|
||||
if mod not in sys.modules:
|
||||
_orig[mod] = None
|
||||
sys.modules[mod] = MagicMock()
|
||||
|
||||
try:
|
||||
from gateway.run import GatewayRunner
|
||||
finally:
|
||||
# Restore only the ones we injected
|
||||
for mod, orig in _orig.items():
|
||||
if orig is None:
|
||||
sys.modules.pop(mod, None)
|
||||
|
||||
runner = GatewayRunner.__new__(GatewayRunner)
|
||||
runner.config = config
|
||||
runner.adapters = {}
|
||||
runner._failed_platforms = {}
|
||||
runner._session_model_overrides = {}
|
||||
return runner, GatewayRunner
|
||||
|
||||
def test_runner_creates_yuanbao_adapter(self):
|
||||
"""GatewayRunner._create_adapter 能为 YUANBAO 返回 YuanbaoAdapter 实例"""
|
||||
from gateway.config import GatewayConfig
|
||||
from unittest.mock import patch
|
||||
config = make_config(enabled=True)
|
||||
gw_config = GatewayConfig(platforms={Platform.YUANBAO: config})
|
||||
|
||||
try:
|
||||
runner, _ = self._make_minimal_runner(gw_config)
|
||||
# websockets 在测试环境可能未安装,mock 掉 WEBSOCKETS_AVAILABLE
|
||||
with patch("gateway.platforms.yuanbao.WEBSOCKETS_AVAILABLE", True):
|
||||
adapter = runner._create_adapter(Platform.YUANBAO, config)
|
||||
except ImportError as e:
|
||||
pytest.skip(f"run.py import unavailable in test env: {e}")
|
||||
|
||||
assert adapter is not None
|
||||
assert isinstance(adapter, YuanbaoAdapter)
|
||||
|
||||
def test_runner_adapter_platform_attr(self):
|
||||
"""创建的 adapter.PLATFORM 为 Platform.YUANBAO"""
|
||||
from gateway.config import GatewayConfig
|
||||
from unittest.mock import patch
|
||||
config = make_config(enabled=True)
|
||||
gw_config = GatewayConfig(platforms={Platform.YUANBAO: config})
|
||||
|
||||
try:
|
||||
runner, _ = self._make_minimal_runner(gw_config)
|
||||
with patch("gateway.platforms.yuanbao.WEBSOCKETS_AVAILABLE", True):
|
||||
adapter = runner._create_adapter(Platform.YUANBAO, config)
|
||||
except ImportError as e:
|
||||
pytest.skip(f"run.py import unavailable in test env: {e}")
|
||||
|
||||
assert adapter is not None
|
||||
assert adapter.PLATFORM == Platform.YUANBAO
|
||||
|
||||
|
||||
# ===========================================================
|
||||
# 4. Proto round-trip
|
||||
# ===========================================================
|
||||
|
||||
class TestProtoRoundTrip:
|
||||
"""验证 proto 编解码基本功能"""
|
||||
|
||||
def test_conn_msg_roundtrip(self):
|
||||
from gateway.platforms.yuanbao_proto import encode_conn_msg, decode_conn_msg
|
||||
encoded = encode_conn_msg(msg_type=1, seq_no=42, data=b"hello")
|
||||
decoded = decode_conn_msg(encoded)
|
||||
assert decoded["seq_no"] == 42
|
||||
assert decoded["data"] == b"hello"
|
||||
|
||||
def test_text_elem_encoding(self):
|
||||
from gateway.platforms.yuanbao_proto import encode_send_c2c_message
|
||||
msg = encode_send_c2c_message(
|
||||
to_account="user123",
|
||||
msg_body=[{"msg_type": "TIMTextElem", "msg_content": {"text": "hello"}}],
|
||||
from_account="bot456",
|
||||
)
|
||||
assert isinstance(msg, bytes)
|
||||
assert len(msg) > 0
|
||||
|
||||
|
||||
# ===========================================================
|
||||
# 5. Markdown 分块
|
||||
# ===========================================================
|
||||
|
||||
class TestMarkdownChunking:
|
||||
def test_chunks_are_sent_separately(self):
|
||||
from gateway.platforms.yuanbao import MarkdownProcessor
|
||||
long_text = "paragraph\n\n" * 100
|
||||
chunks = MarkdownProcessor.chunk_markdown_text(long_text, 200)
|
||||
assert len(chunks) > 1
|
||||
for c in chunks:
|
||||
# 段落原子块允许轻微超限,仅验证不崩溃
|
||||
assert isinstance(c, str)
|
||||
assert len(c) > 0
|
||||
|
||||
def test_chunk_short_text_no_split(self):
|
||||
from gateway.platforms.yuanbao import MarkdownProcessor
|
||||
text = "hello world"
|
||||
chunks = MarkdownProcessor.chunk_markdown_text(text, 3000)
|
||||
assert chunks == [text]
|
||||
|
||||
|
||||
# ===========================================================
|
||||
# 6. Sign Token 模块
|
||||
# ===========================================================
|
||||
|
||||
class TestSignToken:
|
||||
def test_import_ok(self):
|
||||
from gateway.platforms.yuanbao import SignManager
|
||||
assert callable(SignManager.get_token)
|
||||
assert callable(SignManager.force_refresh)
|
||||
|
||||
|
||||
# ===========================================================
|
||||
# 6b. ConnectionManager / OutboundManager
|
||||
# ===========================================================
|
||||
|
||||
class TestManagerImports:
|
||||
def test_connection_manager_import(self):
|
||||
from gateway.platforms.yuanbao import ConnectionManager
|
||||
assert ConnectionManager is not None
|
||||
|
||||
def test_outbound_manager_import(self):
|
||||
from gateway.platforms.yuanbao import OutboundManager
|
||||
assert OutboundManager is not None
|
||||
|
||||
def test_message_sender_import(self):
|
||||
from gateway.platforms.yuanbao import MessageSender
|
||||
assert MessageSender is not None
|
||||
|
||||
def test_heartbeat_manager_import(self):
|
||||
from gateway.platforms.yuanbao import HeartbeatManager
|
||||
assert HeartbeatManager is not None
|
||||
|
||||
def test_slow_response_notifier_import(self):
|
||||
from gateway.platforms.yuanbao import SlowResponseNotifier
|
||||
assert SlowResponseNotifier is not None
|
||||
|
||||
def test_adapter_has_outbound_manager(self):
|
||||
adapter = YuanbaoAdapter(make_config())
|
||||
from gateway.platforms.yuanbao import ConnectionManager, OutboundManager
|
||||
assert isinstance(adapter._connection, ConnectionManager)
|
||||
assert isinstance(adapter._outbound, OutboundManager)
|
||||
|
||||
def test_outbound_composes_sub_managers(self):
|
||||
adapter = YuanbaoAdapter(make_config())
|
||||
from gateway.platforms.yuanbao import MessageSender, HeartbeatManager, SlowResponseNotifier
|
||||
assert isinstance(adapter._outbound.sender, MessageSender)
|
||||
assert isinstance(adapter._outbound.heartbeat, HeartbeatManager)
|
||||
assert isinstance(adapter._outbound.slow_notifier, SlowResponseNotifier)
|
||||
|
||||
|
||||
# ===========================================================
|
||||
# 7. Media 模块
|
||||
# ===========================================================
|
||||
|
||||
class TestMediaModule:
|
||||
def test_import_ok(self):
|
||||
from gateway.platforms.yuanbao_media import upload_to_cos, download_url
|
||||
assert callable(upload_to_cos)
|
||||
assert callable(download_url)
|
||||
|
||||
|
||||
# ===========================================================
|
||||
# 8. Toolset 注册
|
||||
# ===========================================================
|
||||
|
||||
class TestToolset:
|
||||
def test_yuanbao_toolset_registered(self):
|
||||
"""toolsets.py 中存在 hermes-yuanbao 键"""
|
||||
import importlib
|
||||
ts = importlib.import_module("toolsets")
|
||||
assert hasattr(ts, "TOOLSETS") or hasattr(ts, "toolsets")
|
||||
toolsets_dict = getattr(ts, "TOOLSETS", getattr(ts, "toolsets", {}))
|
||||
assert "hermes-yuanbao" in toolsets_dict
|
||||
|
||||
def test_tools_import(self):
|
||||
from tools.yuanbao_tools import (
|
||||
get_group_info,
|
||||
query_group_members,
|
||||
send_dm,
|
||||
)
|
||||
assert all(callable(f) for f in [
|
||||
get_group_info,
|
||||
query_group_members,
|
||||
send_dm,
|
||||
])
|
||||
|
||||
|
||||
# ===========================================================
|
||||
# 9. platforms/__init__.py 导出
|
||||
# ===========================================================
|
||||
|
||||
class TestPlatformInit:
|
||||
def test_yuanbao_adapter_exported(self):
|
||||
"""gateway.platforms.__init__.py 应导出 YuanbaoAdapter"""
|
||||
from gateway.platforms import YuanbaoAdapter as _YuanbaoAdapter
|
||||
assert _YuanbaoAdapter is YuanbaoAdapter
|
||||
|
||||
|
||||
# ===========================================================
|
||||
# 10. P0 fixes verification
|
||||
# ===========================================================
|
||||
|
||||
import asyncio
|
||||
import collections
|
||||
|
||||
|
||||
class TestP0ReconnectGuard:
|
||||
"""P0-1: _reconnecting flag prevents concurrent reconnect attempts."""
|
||||
|
||||
def test_reconnecting_flag_initialized(self):
|
||||
adapter = YuanbaoAdapter(make_config())
|
||||
assert hasattr(adapter._connection, '_reconnecting')
|
||||
assert adapter._connection._reconnecting is False
|
||||
|
||||
def test_schedule_reconnect_skips_when_not_running(self):
|
||||
adapter = YuanbaoAdapter(make_config())
|
||||
adapter._running = False
|
||||
adapter._connection._reconnecting = False
|
||||
adapter._connection.schedule_reconnect()
|
||||
# No task should be created because _running is False
|
||||
|
||||
def test_schedule_reconnect_skips_when_already_reconnecting(self):
|
||||
adapter = YuanbaoAdapter(make_config())
|
||||
adapter._running = True
|
||||
adapter._connection._reconnecting = True
|
||||
adapter._connection.schedule_reconnect()
|
||||
# No new task should be created because already reconnecting
|
||||
|
||||
|
||||
class TestP0InboundTaskTracking:
|
||||
"""P0-2: _inbound_tasks set is initialized and usable."""
|
||||
|
||||
def test_inbound_tasks_initialized(self):
|
||||
adapter = YuanbaoAdapter(make_config())
|
||||
assert hasattr(adapter, '_inbound_tasks')
|
||||
assert isinstance(adapter._inbound_tasks, set)
|
||||
assert len(adapter._inbound_tasks) == 0
|
||||
|
||||
|
||||
class TestP0ChatLockEviction:
|
||||
"""P0-3: get_chat_lock uses OrderedDict and safe eviction."""
|
||||
|
||||
def test_chat_locks_is_ordered_dict(self):
|
||||
adapter = YuanbaoAdapter(make_config())
|
||||
assert isinstance(adapter._outbound._chat_locks, collections.OrderedDict)
|
||||
|
||||
def test_eviction_skips_locked(self):
|
||||
"""When eviction is needed, locked entries are skipped."""
|
||||
adapter = YuanbaoAdapter(make_config())
|
||||
from gateway.platforms.yuanbao import OutboundManager
|
||||
|
||||
# Fill to capacity with unlocked locks
|
||||
for i in range(OutboundManager.CHAT_DICT_MAX_SIZE):
|
||||
adapter._outbound._chat_locks[f"chat_{i}"] = asyncio.Lock()
|
||||
|
||||
# Lock the oldest entry
|
||||
oldest_key = next(iter(adapter._outbound._chat_locks))
|
||||
oldest_lock = adapter._outbound._chat_locks[oldest_key]
|
||||
# Simulate a held lock by acquiring it in a non-async way (set _locked)
|
||||
# asyncio.Lock is not held until actually acquired; so we test the
|
||||
# method logic by acquiring the first lock manually.
|
||||
# For a sync test, we check that get_chat_lock doesn't crash.
|
||||
new_lock = adapter._outbound.get_chat_lock("new_chat")
|
||||
assert "new_chat" in adapter._outbound._chat_locks
|
||||
assert isinstance(new_lock, asyncio.Lock)
|
||||
# The oldest unlocked entry should have been evicted
|
||||
assert len(adapter._outbound._chat_locks) == OutboundManager.CHAT_DICT_MAX_SIZE
|
||||
|
||||
def test_move_to_end_on_access(self):
|
||||
"""Accessing an existing key moves it to the end (MRU)."""
|
||||
adapter = YuanbaoAdapter(make_config())
|
||||
adapter._outbound._chat_locks["a"] = asyncio.Lock()
|
||||
adapter._outbound._chat_locks["b"] = asyncio.Lock()
|
||||
adapter._outbound._chat_locks["c"] = asyncio.Lock()
|
||||
|
||||
# Access "a" — should move to end
|
||||
adapter._outbound.get_chat_lock("a")
|
||||
keys = list(adapter._outbound._chat_locks.keys())
|
||||
assert keys[-1] == "a"
|
||||
assert keys[0] == "b"
|
||||
|
||||
|
||||
class TestP0PlatformScopedLock:
|
||||
"""P0-4: connect() calls _acquire_platform_lock."""
|
||||
|
||||
def test_adapter_has_platform_lock_methods(self):
|
||||
adapter = YuanbaoAdapter(make_config())
|
||||
assert hasattr(adapter, '_acquire_platform_lock')
|
||||
assert hasattr(adapter, '_release_platform_lock')
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
324
tests/test_yuanbao_markdown.py
Normal file
324
tests/test_yuanbao_markdown.py
Normal file
@@ -0,0 +1,324 @@
|
||||
"""
|
||||
test_yuanbao_markdown.py - Unit tests for yuanbao_markdown.py
|
||||
|
||||
Run (no pytest needed):
|
||||
cd /root/.openclaw/workspace/hermes-agent
|
||||
python3 tests/test_yuanbao_markdown.py -v
|
||||
|
||||
Or with pytest if available:
|
||||
python3 -m pytest tests/test_yuanbao_markdown.py -v
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import unittest
|
||||
|
||||
# Ensure project root is on the path
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
||||
|
||||
from gateway.platforms.yuanbao import MarkdownProcessor
|
||||
|
||||
|
||||
# ============ has_unclosed_fence ============
|
||||
|
||||
class TestHasUnclosedFence(unittest.TestCase):
|
||||
def test_unclosed_fence(self):
|
||||
self.assertTrue(MarkdownProcessor.has_unclosed_fence("```python\ncode"))
|
||||
|
||||
def test_closed_fence(self):
|
||||
self.assertFalse(MarkdownProcessor.has_unclosed_fence("```python\ncode\n```"))
|
||||
|
||||
def test_empty(self):
|
||||
self.assertFalse(MarkdownProcessor.has_unclosed_fence(""))
|
||||
|
||||
def test_no_fence(self):
|
||||
self.assertFalse(MarkdownProcessor.has_unclosed_fence("just some text\nno fences here"))
|
||||
|
||||
def test_multiple_closed_fences(self):
|
||||
text = "```python\ncode1\n```\n\n```js\ncode2\n```"
|
||||
self.assertFalse(MarkdownProcessor.has_unclosed_fence(text))
|
||||
|
||||
def test_second_fence_unclosed(self):
|
||||
text = "```python\ncode1\n```\n\n```js\ncode2"
|
||||
self.assertTrue(MarkdownProcessor.has_unclosed_fence(text))
|
||||
|
||||
def test_fence_at_start(self):
|
||||
self.assertTrue(MarkdownProcessor.has_unclosed_fence("```\nsome code"))
|
||||
|
||||
def test_inline_backtick_ignored(self):
|
||||
text = "`inline code` is fine"
|
||||
self.assertFalse(MarkdownProcessor.has_unclosed_fence(text))
|
||||
|
||||
|
||||
# ============ ends_with_table_row ============
|
||||
|
||||
class TestEndsWithTableRow(unittest.TestCase):
|
||||
def test_simple_table_row(self):
|
||||
self.assertTrue(MarkdownProcessor.ends_with_table_row("| col1 | col2 |"))
|
||||
|
||||
def test_table_row_with_trailing_newline(self):
|
||||
self.assertTrue(MarkdownProcessor.ends_with_table_row("| col1 | col2 |\n"))
|
||||
|
||||
def test_table_row_in_middle(self):
|
||||
text = "| col1 | col2 |\nsome other text"
|
||||
self.assertFalse(MarkdownProcessor.ends_with_table_row(text))
|
||||
|
||||
def test_empty(self):
|
||||
self.assertFalse(MarkdownProcessor.ends_with_table_row(""))
|
||||
|
||||
def test_non_table(self):
|
||||
self.assertFalse(MarkdownProcessor.ends_with_table_row("just a normal line"))
|
||||
|
||||
def test_only_pipe_start(self):
|
||||
self.assertFalse(MarkdownProcessor.ends_with_table_row("| just pipe at start"))
|
||||
|
||||
def test_table_separator_row(self):
|
||||
self.assertTrue(MarkdownProcessor.ends_with_table_row("| --- | --- |"))
|
||||
|
||||
def test_whitespace_only(self):
|
||||
self.assertFalse(MarkdownProcessor.ends_with_table_row(" \n "))
|
||||
|
||||
|
||||
# ============ split_at_paragraph_boundary ============
|
||||
|
||||
class TestSplitAtParagraphBoundary(unittest.TestCase):
|
||||
def test_split_at_empty_line(self):
|
||||
text = "paragraph one\n\nparagraph two\n\nparagraph three\nextra"
|
||||
head, tail = MarkdownProcessor.split_at_paragraph_boundary(text, 30)
|
||||
self.assertLessEqual(len(head), 30)
|
||||
self.assertEqual(head + tail, text)
|
||||
|
||||
def test_split_at_sentence_end(self):
|
||||
text = "This is a sentence.\nNext line.\nAnother line."
|
||||
head, tail = MarkdownProcessor.split_at_paragraph_boundary(text, 25)
|
||||
self.assertLessEqual(len(head), 25)
|
||||
self.assertEqual(head + tail, text)
|
||||
|
||||
def test_forced_split_no_boundary(self):
|
||||
text = "a" * 100
|
||||
head, tail = MarkdownProcessor.split_at_paragraph_boundary(text, 50)
|
||||
self.assertEqual(len(head), 50)
|
||||
self.assertEqual(head + tail, text)
|
||||
|
||||
def test_split_at_newline(self):
|
||||
text = "line one\nline two\nline three"
|
||||
head, tail = MarkdownProcessor.split_at_paragraph_boundary(text, 15)
|
||||
self.assertLessEqual(len(head), 15)
|
||||
self.assertEqual(head + tail, text)
|
||||
|
||||
def test_chinese_sentence_boundary(self):
|
||||
text = "这是第一句话。\n这是第二句话。\n这是第三句话。"
|
||||
head, tail = MarkdownProcessor.split_at_paragraph_boundary(text, 15)
|
||||
self.assertLessEqual(len(head), 15)
|
||||
self.assertEqual(head + tail, text)
|
||||
|
||||
|
||||
# ============ chunk_markdown_text ============
|
||||
|
||||
class TestChunkMarkdownText(unittest.TestCase):
|
||||
def test_empty(self):
|
||||
self.assertEqual(MarkdownProcessor.chunk_markdown_text(""), [])
|
||||
|
||||
def test_short_text_no_split(self):
|
||||
text = "hello world"
|
||||
self.assertEqual(MarkdownProcessor.chunk_markdown_text(text, 3000), [text])
|
||||
|
||||
def test_exactly_max_chars(self):
|
||||
text = "a" * 3000
|
||||
result = MarkdownProcessor.chunk_markdown_text(text, 3000)
|
||||
self.assertEqual(len(result), 1)
|
||||
self.assertEqual(result[0], text)
|
||||
|
||||
def test_plain_text_split(self):
|
||||
"""x * 9000 should return 3 chunks of ~3000"""
|
||||
text = "x" * 9000
|
||||
result = MarkdownProcessor.chunk_markdown_text(text, 3000)
|
||||
self.assertEqual(len(result), 3)
|
||||
for chunk in result:
|
||||
self.assertLessEqual(len(chunk), 3000)
|
||||
self.assertEqual(''.join(result), text)
|
||||
|
||||
def test_5000_chars_returns_2(self):
|
||||
"""验收标准: 'a'*5000 with max 3000 → 2 chunks"""
|
||||
result = MarkdownProcessor.chunk_markdown_text("a" * 5000, 3000)
|
||||
self.assertEqual(len(result), 2)
|
||||
|
||||
def test_code_fence_not_split(self):
|
||||
"""代码块不应被切断"""
|
||||
code_lines = "\n".join([f" line_{i} = {i}" for i in range(200)])
|
||||
text = f"Some intro text.\n\n```python\n{code_lines}\n```\n\nSome outro text."
|
||||
result = MarkdownProcessor.chunk_markdown_text(text, 3000)
|
||||
for chunk in result:
|
||||
self.assertFalse(MarkdownProcessor.has_unclosed_fence(chunk),
|
||||
f"Chunk has unclosed fence:\n{chunk[:200]}...")
|
||||
|
||||
def test_table_not_split(self):
|
||||
"""表格行不应被切断"""
|
||||
header = "| Name | Value | Description |\n| --- | --- | --- |"
|
||||
rows = "\n".join([f"| item_{i} | {i * 100} | description for item {i} |"
|
||||
for i in range(50)])
|
||||
table = f"{header}\n{rows}"
|
||||
text = "Some intro text.\n\n" + table + "\n\nSome outro text."
|
||||
result = MarkdownProcessor.chunk_markdown_text(text, 3000)
|
||||
for chunk in result:
|
||||
self.assertFalse(MarkdownProcessor.has_unclosed_fence(chunk))
|
||||
|
||||
def test_code_fence_200_lines_not_cut(self):
|
||||
"""包含 200 行代码块的文本,代码块不被切断"""
|
||||
code_lines = "\n".join([f"x = {i}" for i in range(200)])
|
||||
text = f"Intro.\n\n```python\n{code_lines}\n```\n\nOutro."
|
||||
result = MarkdownProcessor.chunk_markdown_text(text, 3000)
|
||||
for chunk in result:
|
||||
self.assertFalse(MarkdownProcessor.has_unclosed_fence(chunk))
|
||||
|
||||
def test_multiple_paragraphs(self):
|
||||
"""多段落文本应在段落边界切割"""
|
||||
paragraphs = ["This is paragraph number " + str(i) + ". " * 50
|
||||
for i in range(10)]
|
||||
text = "\n\n".join(paragraphs)
|
||||
result = MarkdownProcessor.chunk_markdown_text(text, 500)
|
||||
self.assertGreater(len(result), 1)
|
||||
total_content = ''.join(result)
|
||||
self.assertGreaterEqual(len(total_content), len(text) * 0.95)
|
||||
|
||||
def test_single_long_line(self):
|
||||
"""单行超长文本应被强制切割"""
|
||||
text = "a" * 10000
|
||||
result = MarkdownProcessor.chunk_markdown_text(text, 3000)
|
||||
self.assertGreaterEqual(len(result), 3)
|
||||
for c in result:
|
||||
self.assertLessEqual(len(c), 3000)
|
||||
|
||||
def test_fence_followed_by_text(self):
|
||||
"""围栏后的文本应正常切割"""
|
||||
text = "```python\nprint('hi')\n```\n\n" + "Normal text. " * 300
|
||||
result = MarkdownProcessor.chunk_markdown_text(text, 500)
|
||||
for chunk in result:
|
||||
self.assertFalse(MarkdownProcessor.has_unclosed_fence(chunk))
|
||||
|
||||
def test_returns_non_empty_strings(self):
|
||||
"""所有返回的片段都应为非空字符串"""
|
||||
text = "Hello world!\n\n" * 100
|
||||
result = MarkdownProcessor.chunk_markdown_text(text, 100)
|
||||
for chunk in result:
|
||||
self.assertGreater(len(chunk), 0)
|
||||
|
||||
|
||||
# ============ Acceptance criteria ============
|
||||
|
||||
class TestAcceptanceCriteria(unittest.TestCase):
|
||||
def test_9000_x_returns_3_chunks(self):
|
||||
"""验收:MarkdownProcessor.chunk_markdown_text("x" * 9000, 3000) 返回 3 个片段"""
|
||||
result = MarkdownProcessor.chunk_markdown_text("x" * 9000, 3000)
|
||||
self.assertEqual(len(result), 3)
|
||||
for chunk in result:
|
||||
self.assertLessEqual(len(chunk), 3000)
|
||||
|
||||
def test_5000_a_returns_2_chunks(self):
|
||||
"""验收:python -c 输出 2"""
|
||||
result = MarkdownProcessor.chunk_markdown_text("a" * 5000, 3000)
|
||||
self.assertEqual(len(result), 2)
|
||||
|
||||
def test_has_unclosed_fence_true(self):
|
||||
"""验收:MarkdownProcessor.has_unclosed_fence("```python\\ncode") 返回 True"""
|
||||
self.assertTrue(MarkdownProcessor.has_unclosed_fence("```python\ncode"))
|
||||
|
||||
def test_has_unclosed_fence_false(self):
|
||||
"""验收:MarkdownProcessor.has_unclosed_fence("```python\\ncode\\n```") 返回 False"""
|
||||
self.assertFalse(MarkdownProcessor.has_unclosed_fence("```python\ncode\n```"))
|
||||
|
||||
def test_code_block_200_lines_not_broken(self):
|
||||
"""验收:包含 200 行代码块的文本,代码块不被切断"""
|
||||
code_lines = "\n".join([f" result_{i} = compute({i})" for i in range(200)])
|
||||
text = f"Introduction.\n\n```python\n{code_lines}\n```\n\nConclusion."
|
||||
result = MarkdownProcessor.chunk_markdown_text(text, 3000)
|
||||
for chunk in result:
|
||||
self.assertFalse(MarkdownProcessor.has_unclosed_fence(chunk),
|
||||
f"Found unclosed fence in chunk:\n{chunk[:100]}...")
|
||||
|
||||
def test_table_rows_not_broken(self):
|
||||
"""验收:表格行不被切断(每个 chunk 中的表格 fence 完整)"""
|
||||
rows = "\n".join([
|
||||
f"| Col A {i} | Col B {i} | Col C {i} |" for i in range(100)
|
||||
])
|
||||
text = f"Table:\n\n| A | B | C |\n| --- | --- | --- |\n{rows}\n\nDone."
|
||||
result = MarkdownProcessor.chunk_markdown_text(text, 500)
|
||||
for chunk in result:
|
||||
self.assertFalse(MarkdownProcessor.has_unclosed_fence(chunk))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main(verbosity=2)
|
||||
|
||||
|
||||
# ============ pytest-style function tests (task specification) ============
|
||||
|
||||
def test_short_text_no_split():
|
||||
assert MarkdownProcessor.chunk_markdown_text("hello", 100) == ["hello"]
|
||||
|
||||
|
||||
def test_plain_text_split():
|
||||
chunks = MarkdownProcessor.chunk_markdown_text("a" * 5000, 3000)
|
||||
assert len(chunks) >= 2
|
||||
for c in chunks:
|
||||
assert len(c) <= 3000
|
||||
|
||||
|
||||
def test_fence_not_broken():
|
||||
"""代码块不应被切断"""
|
||||
code_block = "```python\n" + "x = 1\n" * 200 + "```"
|
||||
chunks = MarkdownProcessor.chunk_markdown_text(code_block, 1000)
|
||||
for c in chunks:
|
||||
assert not MarkdownProcessor.has_unclosed_fence(c), f"Chunk has unclosed fence: {c[:100]}"
|
||||
|
||||
|
||||
def test_large_fence_kept_whole():
|
||||
"""超大代码块即便超过 max_chars 也应整块输出"""
|
||||
code_block = "```python\n" + "x = 1\n" * 200 + "```"
|
||||
chunks = MarkdownProcessor.chunk_markdown_text(code_block, 500)
|
||||
# 代码块应在同一个 chunk 中(允许超出 max_chars)
|
||||
fence_chunks = [c for c in chunks if "```python" in c]
|
||||
for c in fence_chunks:
|
||||
assert not MarkdownProcessor.has_unclosed_fence(c)
|
||||
|
||||
|
||||
def test_mixed_content():
|
||||
"""代码块前后的普通文本可以正常切割"""
|
||||
text = "intro paragraph\n\n" + "```python\nx=1\n```" + "\n\noutro paragraph"
|
||||
chunks = MarkdownProcessor.chunk_markdown_text(text, 100)
|
||||
for c in chunks:
|
||||
assert not MarkdownProcessor.has_unclosed_fence(c)
|
||||
|
||||
|
||||
def test_table_not_broken():
|
||||
"""表格不应被切断"""
|
||||
table = "| A | B |\n|---|---|\n| 1 | 2 |\n| 3 | 4 |"
|
||||
text = "before\n\n" + table + "\n\nafter"
|
||||
chunks = MarkdownProcessor.chunk_markdown_text(text, 30)
|
||||
table_in_chunk = [c for c in chunks if "|" in c]
|
||||
for c in table_in_chunk:
|
||||
lines = [line for line in c.split('\n') if line.strip().startswith('|')]
|
||||
if lines:
|
||||
# 至少表格行不被半截切割
|
||||
pass
|
||||
|
||||
|
||||
def test_has_unclosed_fence():
|
||||
assert MarkdownProcessor.has_unclosed_fence("```python\ncode") == True
|
||||
assert MarkdownProcessor.has_unclosed_fence("```python\ncode\n```") == False
|
||||
assert MarkdownProcessor.has_unclosed_fence("no fence") == False
|
||||
|
||||
|
||||
def test_ends_with_table_row():
|
||||
assert MarkdownProcessor.ends_with_table_row("| a | b |") == True
|
||||
assert MarkdownProcessor.ends_with_table_row("normal text") == False
|
||||
|
||||
|
||||
def test_empty_text():
|
||||
assert MarkdownProcessor.chunk_markdown_text("", 100) == []
|
||||
|
||||
|
||||
def test_exact_limit():
|
||||
text = "a" * 3000
|
||||
chunks = MarkdownProcessor.chunk_markdown_text(text, 3000)
|
||||
assert len(chunks) == 1
|
||||
1029
tests/test_yuanbao_pipeline.py
Normal file
1029
tests/test_yuanbao_pipeline.py
Normal file
File diff suppressed because it is too large
Load Diff
654
tests/test_yuanbao_proto.py
Normal file
654
tests/test_yuanbao_proto.py
Normal file
@@ -0,0 +1,654 @@
|
||||
"""
|
||||
test_yuanbao_proto.py - yuanbao_proto 单元测试
|
||||
|
||||
测试覆盖:
|
||||
1. varint 编解码 round-trip
|
||||
2. conn 层 encode/decode round-trip
|
||||
3. biz 层 encode/decode round-trip
|
||||
4. decode_inbound_push 解析 TIMTextElem 消息
|
||||
5. encode_send_c2c_message / encode_send_group_message 编码
|
||||
6. 固定 bytes 常量验证(防止协议悄悄改动)
|
||||
7. auth-bind / ping 编码
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
# 确保 hermes-agent 根目录在 sys.path 中
|
||||
_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
if _REPO_ROOT not in sys.path:
|
||||
sys.path.insert(0, _REPO_ROOT)
|
||||
|
||||
import pytest
|
||||
from gateway.platforms.yuanbao_proto import (
|
||||
# 基础工具
|
||||
_encode_varint,
|
||||
_decode_varint,
|
||||
_parse_fields,
|
||||
_fields_to_dict,
|
||||
_encode_msg_body_element,
|
||||
_decode_msg_body_element,
|
||||
_encode_msg_content,
|
||||
_decode_msg_content,
|
||||
# conn 层
|
||||
encode_conn_msg,
|
||||
decode_conn_msg,
|
||||
encode_conn_msg_full,
|
||||
# biz 层
|
||||
encode_biz_msg,
|
||||
decode_biz_msg,
|
||||
# 入站/出站
|
||||
decode_inbound_push,
|
||||
encode_send_c2c_message,
|
||||
encode_send_group_message,
|
||||
# 帮助函数
|
||||
encode_auth_bind,
|
||||
encode_ping,
|
||||
encode_push_ack,
|
||||
# 常量
|
||||
PB_MSG_TYPES,
|
||||
BIZ_SERVICES,
|
||||
CMD_TYPE,
|
||||
CMD,
|
||||
MODULE,
|
||||
next_seq_no,
|
||||
)
|
||||
|
||||
|
||||
# ===========================================================
|
||||
# 1. varint 编解码
|
||||
# ===========================================================
|
||||
|
||||
class TestVarint:
|
||||
def test_small_values(self):
|
||||
for v in [0, 1, 127, 128, 255, 300, 16383, 16384, 2**21, 2**28]:
|
||||
encoded = _encode_varint(v)
|
||||
decoded, pos = _decode_varint(encoded, 0)
|
||||
assert decoded == v, f"round-trip failed for {v}"
|
||||
assert pos == len(encoded)
|
||||
|
||||
def test_zero(self):
|
||||
assert _encode_varint(0) == b"\x00"
|
||||
v, p = _decode_varint(b"\x00", 0)
|
||||
assert v == 0 and p == 1
|
||||
|
||||
def test_1_byte_boundary(self):
|
||||
# 127 = 0x7F => 1 byte
|
||||
assert _encode_varint(127) == b"\x7f"
|
||||
# 128 => 2 bytes: 0x80 0x01
|
||||
assert _encode_varint(128) == b"\x80\x01"
|
||||
|
||||
def test_known_values(self):
|
||||
# protobuf spec examples
|
||||
# 300 => 0xAC 0x02
|
||||
assert _encode_varint(300) == bytes([0xAC, 0x02])
|
||||
|
||||
def test_multi_byte(self):
|
||||
# 2^32 - 1 = 4294967295
|
||||
v = 2**32 - 1
|
||||
enc = _encode_varint(v)
|
||||
dec, _ = _decode_varint(enc, 0)
|
||||
assert dec == v
|
||||
|
||||
def test_partial_decode(self):
|
||||
# 在 offset 处解码
|
||||
data = b"\x00" + _encode_varint(300) + b"\x00"
|
||||
v, pos = _decode_varint(data, 1)
|
||||
assert v == 300
|
||||
assert pos == 3 # 1 + 2 bytes for 300
|
||||
|
||||
|
||||
# ===========================================================
|
||||
# 2. conn 层 round-trip
|
||||
# ===========================================================
|
||||
|
||||
class TestConnCodec:
|
||||
def test_basic_round_trip(self):
|
||||
payload = b"hello world"
|
||||
encoded = encode_conn_msg(msg_type=0, seq_no=42, data=payload)
|
||||
decoded = decode_conn_msg(encoded)
|
||||
assert decoded["msg_type"] == 0
|
||||
assert decoded["seq_no"] == 42
|
||||
assert decoded["data"] == payload
|
||||
|
||||
def test_empty_data(self):
|
||||
encoded = encode_conn_msg(msg_type=2, seq_no=0, data=b"")
|
||||
decoded = decode_conn_msg(encoded)
|
||||
assert decoded["msg_type"] == 2
|
||||
assert decoded["data"] == b""
|
||||
|
||||
def test_all_cmd_types(self):
|
||||
for ct in [0, 1, 2, 3]:
|
||||
enc = encode_conn_msg(msg_type=ct, seq_no=1, data=b"\x01\x02")
|
||||
dec = decode_conn_msg(enc)
|
||||
assert dec["msg_type"] == ct
|
||||
|
||||
def test_large_seq_no(self):
|
||||
enc = encode_conn_msg(msg_type=1, seq_no=2**32 - 1, data=b"x")
|
||||
dec = decode_conn_msg(enc)
|
||||
assert dec["seq_no"] == 2**32 - 1
|
||||
|
||||
def test_full_round_trip(self):
|
||||
"""encode_conn_msg_full 含 cmd/msg_id/module"""
|
||||
enc = encode_conn_msg_full(
|
||||
cmd_type=CMD_TYPE["Request"],
|
||||
cmd="auth-bind",
|
||||
seq_no=99,
|
||||
msg_id="abc123",
|
||||
module="conn_access",
|
||||
data=b"\xde\xad\xbe\xef",
|
||||
)
|
||||
dec = decode_conn_msg(enc)
|
||||
head = dec["head"]
|
||||
assert head["cmd_type"] == CMD_TYPE["Request"]
|
||||
assert head["cmd"] == "auth-bind"
|
||||
assert head["seq_no"] == 99
|
||||
assert head["msg_id"] == "abc123"
|
||||
assert head["module"] == "conn_access"
|
||||
assert dec["data"] == b"\xde\xad\xbe\xef"
|
||||
|
||||
# 固定 bytes 常量测试——防协议悄悄改动
|
||||
def test_fixed_bytes_simple(self):
|
||||
"""
|
||||
encode_conn_msg(msg_type=0, seq_no=1, data=b"") 的固定编码。
|
||||
ConnMsg { head { seq_no=1 } }
|
||||
head bytes: field3 varint(1) = 0x18 0x01
|
||||
head field: field1 len(2) 0x18 0x01 = 0x0a 0x02 0x18 0x01
|
||||
"""
|
||||
enc = encode_conn_msg(msg_type=0, seq_no=1, data=b"")
|
||||
# head: field 3 (seq_no=1) => tag=0x18, value=0x01
|
||||
head_content = bytes([0x18, 0x01])
|
||||
# outer field 1 (head message)
|
||||
expected = bytes([0x0a, len(head_content)]) + head_content
|
||||
assert enc == expected, f"got: {enc.hex()}, expected: {expected.hex()}"
|
||||
|
||||
|
||||
# ===========================================================
|
||||
# 3. biz 层 round-trip
|
||||
# ===========================================================
|
||||
|
||||
class TestBizCodec:
|
||||
def test_round_trip(self):
|
||||
body = b"\x0a\x05hello"
|
||||
enc = encode_biz_msg(
|
||||
service="trpc.yuanbao.example",
|
||||
method="/im/send_c2c_msg",
|
||||
req_id="req-001",
|
||||
body=body,
|
||||
)
|
||||
dec = decode_biz_msg(enc)
|
||||
assert dec["service"] == "trpc.yuanbao.example"
|
||||
assert dec["method"] == "/im/send_c2c_msg"
|
||||
assert dec["req_id"] == "req-001"
|
||||
assert dec["body"] == body
|
||||
assert dec["is_response"] is False
|
||||
|
||||
def test_is_response_flag(self):
|
||||
# Response cmd_type = 1
|
||||
enc = encode_conn_msg_full(
|
||||
cmd_type=CMD_TYPE["Response"],
|
||||
cmd="/im/send_c2c_msg",
|
||||
seq_no=1,
|
||||
msg_id="rsp-001",
|
||||
module="svc",
|
||||
data=b"\x01",
|
||||
)
|
||||
dec = decode_biz_msg(enc)
|
||||
assert dec["is_response"] is True
|
||||
|
||||
def test_empty_body(self):
|
||||
enc = encode_biz_msg("svc", "method", "id1", b"")
|
||||
dec = decode_biz_msg(enc)
|
||||
assert dec["body"] == b""
|
||||
assert dec["method"] == "method"
|
||||
|
||||
|
||||
# ===========================================================
|
||||
# 4. MsgContent / MsgBodyElement 编解码
|
||||
# ===========================================================
|
||||
|
||||
class TestMsgBodyElement:
|
||||
def test_text_elem_round_trip(self):
|
||||
el = {
|
||||
"msg_type": "TIMTextElem",
|
||||
"msg_content": {"text": "Hello, 世界!"},
|
||||
}
|
||||
encoded = _encode_msg_body_element(el)
|
||||
decoded = _decode_msg_body_element(encoded)
|
||||
assert decoded["msg_type"] == "TIMTextElem"
|
||||
assert decoded["msg_content"]["text"] == "Hello, 世界!"
|
||||
|
||||
def test_image_elem_round_trip(self):
|
||||
el = {
|
||||
"msg_type": "TIMImageElem",
|
||||
"msg_content": {
|
||||
"uuid": "img-uuid-123",
|
||||
"image_format": 2,
|
||||
"url": "https://example.com/img.jpg",
|
||||
"image_info_array": [
|
||||
{"type": 1, "size": 1024, "width": 100, "height": 200, "url": "https://thumb.jpg"},
|
||||
],
|
||||
},
|
||||
}
|
||||
encoded = _encode_msg_body_element(el)
|
||||
decoded = _decode_msg_body_element(encoded)
|
||||
assert decoded["msg_type"] == "TIMImageElem"
|
||||
mc = decoded["msg_content"]
|
||||
assert mc["uuid"] == "img-uuid-123"
|
||||
assert mc["image_format"] == 2
|
||||
assert mc["url"] == "https://example.com/img.jpg"
|
||||
assert len(mc["image_info_array"]) == 1
|
||||
assert mc["image_info_array"][0]["url"] == "https://thumb.jpg"
|
||||
|
||||
def test_file_elem_round_trip(self):
|
||||
el = {
|
||||
"msg_type": "TIMFileElem",
|
||||
"msg_content": {
|
||||
"url": "https://example.com/file.pdf",
|
||||
"file_size": 204800,
|
||||
"file_name": "document.pdf",
|
||||
},
|
||||
}
|
||||
enc = _encode_msg_body_element(el)
|
||||
dec = _decode_msg_body_element(enc)
|
||||
assert dec["msg_content"]["file_name"] == "document.pdf"
|
||||
assert dec["msg_content"]["file_size"] == 204800
|
||||
|
||||
def test_custom_elem_round_trip(self):
|
||||
el = {
|
||||
"msg_type": "TIMCustomElem",
|
||||
"msg_content": {
|
||||
"data": '{"key":"value"}',
|
||||
"desc": "custom description",
|
||||
"ext": "extra info",
|
||||
},
|
||||
}
|
||||
enc = _encode_msg_body_element(el)
|
||||
dec = _decode_msg_body_element(enc)
|
||||
assert dec["msg_content"]["data"] == '{"key":"value"}'
|
||||
assert dec["msg_content"]["desc"] == "custom description"
|
||||
|
||||
def test_empty_content(self):
|
||||
el = {"msg_type": "TIMTextElem", "msg_content": {}}
|
||||
enc = _encode_msg_body_element(el)
|
||||
dec = _decode_msg_body_element(enc)
|
||||
assert dec["msg_type"] == "TIMTextElem"
|
||||
|
||||
def test_fixed_text_elem_bytes(self):
|
||||
"""
|
||||
固定 bytes 验证:TIMTextElem { text="hi" }
|
||||
MsgBodyElement:
|
||||
field1 (msg_type="TIMTextElem"): 0a 0b 54494d5465787445 6c656d
|
||||
field2 (msg_content): 12 <len> <content>
|
||||
MsgContent field1 (text="hi"): 0a 02 6869
|
||||
"""
|
||||
el = {
|
||||
"msg_type": "TIMTextElem",
|
||||
"msg_content": {"text": "hi"},
|
||||
}
|
||||
enc = _encode_msg_body_element(el)
|
||||
# 手动计算期望值
|
||||
# msg_type = "TIMTextElem" (11 bytes)
|
||||
type_bytes = b"TIMTextElem"
|
||||
# MsgContent: field1(text="hi") = tag(0a) + len(02) + "hi"
|
||||
content_inner = bytes([0x0a, 0x02]) + b"hi"
|
||||
# MsgBodyElement:
|
||||
# field1: tag=0x0a, len=11, type_bytes
|
||||
# field2: tag=0x12, len=len(content_inner), content_inner
|
||||
expected = (
|
||||
bytes([0x0a, len(type_bytes)]) + type_bytes
|
||||
+ bytes([0x12, len(content_inner)]) + content_inner
|
||||
)
|
||||
assert enc == expected, f"got {enc.hex()}, expected {expected.hex()}"
|
||||
|
||||
|
||||
# ===========================================================
|
||||
# 5. decode_inbound_push 测试
|
||||
# ===========================================================
|
||||
|
||||
class TestDecodeInboundPush:
|
||||
def _build_inbound_push_bytes(
|
||||
self,
|
||||
from_account: str = "user123",
|
||||
to_account: str = "bot456",
|
||||
group_code: str = "",
|
||||
msg_key: str = "key-001",
|
||||
msg_seq: int = 12345,
|
||||
text: str = "Hello!",
|
||||
) -> bytes:
|
||||
"""手工构造 InboundMessagePush bytes(与 proto 字段顺序一致)"""
|
||||
from gateway.platforms.yuanbao_proto import (
|
||||
_encode_field, _encode_string, _encode_message,
|
||||
_encode_varint, WT_LEN, WT_VARINT,
|
||||
)
|
||||
el = {
|
||||
"msg_type": "TIMTextElem",
|
||||
"msg_content": {"text": text},
|
||||
}
|
||||
el_bytes = _encode_msg_body_element(el)
|
||||
|
||||
buf = b""
|
||||
buf += _encode_field(2, WT_LEN, _encode_string(from_account)) # from_account
|
||||
buf += _encode_field(3, WT_LEN, _encode_string(to_account)) # to_account
|
||||
if group_code:
|
||||
buf += _encode_field(6, WT_LEN, _encode_string(group_code)) # group_code
|
||||
buf += _encode_field(8, WT_VARINT, _encode_varint(msg_seq)) # msg_seq
|
||||
buf += _encode_field(11, WT_LEN, _encode_string(msg_key)) # msg_key
|
||||
buf += _encode_field(13, WT_LEN, _encode_message(el_bytes)) # msg_body[0]
|
||||
return buf
|
||||
|
||||
def test_basic_c2c_text_message(self):
|
||||
raw = self._build_inbound_push_bytes(
|
||||
from_account="alice",
|
||||
to_account="bot",
|
||||
msg_key="k001",
|
||||
msg_seq=100,
|
||||
text="你好",
|
||||
)
|
||||
result = decode_inbound_push(raw)
|
||||
assert result is not None
|
||||
assert result["from_account"] == "alice"
|
||||
assert result["to_account"] == "bot"
|
||||
assert result["msg_seq"] == 100
|
||||
assert result["msg_key"] == "k001"
|
||||
assert len(result["msg_body"]) == 1
|
||||
assert result["msg_body"][0]["msg_type"] == "TIMTextElem"
|
||||
assert result["msg_body"][0]["msg_content"]["text"] == "你好"
|
||||
|
||||
def test_group_message(self):
|
||||
raw = self._build_inbound_push_bytes(
|
||||
from_account="bob",
|
||||
to_account="bot",
|
||||
group_code="group-789",
|
||||
msg_seq=999,
|
||||
text="group msg",
|
||||
)
|
||||
result = decode_inbound_push(raw)
|
||||
assert result is not None
|
||||
assert result["group_code"] == "group-789"
|
||||
assert result["msg_body"][0]["msg_content"]["text"] == "group msg"
|
||||
|
||||
def test_returns_none_on_empty(self):
|
||||
# 空 bytes 应返回空字段 dict,而不是 None
|
||||
result = decode_inbound_push(b"")
|
||||
# 空消息解析结果是 {}(无字段),过滤后 msg_body=[] 也会保留
|
||||
assert result is not None or result is None # 不崩溃即可
|
||||
|
||||
def test_multiple_msg_body_elements(self):
|
||||
from gateway.platforms.yuanbao_proto import (
|
||||
_encode_field, _encode_message, WT_LEN,
|
||||
)
|
||||
el1 = _encode_msg_body_element(
|
||||
{"msg_type": "TIMTextElem", "msg_content": {"text": "part1"}}
|
||||
)
|
||||
el2 = _encode_msg_body_element(
|
||||
{"msg_type": "TIMTextElem", "msg_content": {"text": "part2"}}
|
||||
)
|
||||
buf = (
|
||||
_encode_field(2, WT_LEN, b"\x05alice")
|
||||
+ _encode_field(13, WT_LEN, _encode_message(el1))
|
||||
+ _encode_field(13, WT_LEN, _encode_message(el2))
|
||||
)
|
||||
result = decode_inbound_push(buf)
|
||||
assert result is not None
|
||||
assert len(result["msg_body"]) == 2
|
||||
assert result["msg_body"][0]["msg_content"]["text"] == "part1"
|
||||
assert result["msg_body"][1]["msg_content"]["text"] == "part2"
|
||||
|
||||
|
||||
# ===========================================================
|
||||
# 6. 出站消息编码
|
||||
# ===========================================================
|
||||
|
||||
class TestEncodeOutbound:
|
||||
def test_encode_send_c2c_message(self):
|
||||
msg_body = [{"msg_type": "TIMTextElem", "msg_content": {"text": "hi"}}]
|
||||
result = encode_send_c2c_message(
|
||||
to_account="user_b",
|
||||
msg_body=msg_body,
|
||||
from_account="bot",
|
||||
msg_id="msg-001",
|
||||
)
|
||||
assert isinstance(result, bytes)
|
||||
assert len(result) > 0
|
||||
# 解码验证 ConnMsg 结构
|
||||
dec = decode_conn_msg(result)
|
||||
assert dec["head"]["cmd"] == "send_c2c_message"
|
||||
assert dec["head"]["msg_id"] == "msg-001"
|
||||
assert dec["head"]["module"] == "yuanbao_openclaw_proxy"
|
||||
assert len(dec["data"]) > 0
|
||||
|
||||
def test_encode_send_group_message(self):
|
||||
msg_body = [{"msg_type": "TIMTextElem", "msg_content": {"text": "group hello"}}]
|
||||
result = encode_send_group_message(
|
||||
group_code="grp-100",
|
||||
msg_body=msg_body,
|
||||
from_account="bot",
|
||||
msg_id="msg-002",
|
||||
)
|
||||
assert isinstance(result, bytes)
|
||||
dec = decode_conn_msg(result)
|
||||
assert dec["head"]["cmd"] == "send_group_message"
|
||||
assert dec["head"]["msg_id"] == "msg-002"
|
||||
assert len(dec["data"]) > 0
|
||||
|
||||
def test_c2c_biz_payload_contains_to_account(self):
|
||||
"""验证 biz payload 包含 to_account 字段"""
|
||||
from gateway.platforms.yuanbao_proto import _parse_fields, _fields_to_dict, _get_string
|
||||
msg_body = [{"msg_type": "TIMTextElem", "msg_content": {"text": "test"}}]
|
||||
result = encode_send_c2c_message(
|
||||
to_account="target_user",
|
||||
msg_body=msg_body,
|
||||
from_account="bot",
|
||||
)
|
||||
dec = decode_conn_msg(result)
|
||||
biz_data = dec["data"]
|
||||
fdict = _fields_to_dict(_parse_fields(biz_data))
|
||||
to_acc = _get_string(fdict, 2) # SendC2CMessageReq.to_account = field 2
|
||||
assert to_acc == "target_user"
|
||||
|
||||
def test_group_biz_payload_contains_group_code(self):
|
||||
from gateway.platforms.yuanbao_proto import _parse_fields, _fields_to_dict, _get_string
|
||||
msg_body = [{"msg_type": "TIMTextElem", "msg_content": {"text": "test"}}]
|
||||
result = encode_send_group_message(
|
||||
group_code="group-xyz",
|
||||
msg_body=msg_body,
|
||||
from_account="bot",
|
||||
)
|
||||
dec = decode_conn_msg(result)
|
||||
biz_data = dec["data"]
|
||||
fdict = _fields_to_dict(_parse_fields(biz_data))
|
||||
grp = _get_string(fdict, 2) # SendGroupMessageReq.group_code = field 2
|
||||
assert grp == "group-xyz"
|
||||
|
||||
|
||||
# ===========================================================
|
||||
# 7. AuthBind / Ping 编码
|
||||
# ===========================================================
|
||||
|
||||
class TestAuthAndPing:
|
||||
def test_encode_auth_bind(self):
|
||||
result = encode_auth_bind(
|
||||
biz_id="ybBot",
|
||||
uid="user_001",
|
||||
source="app",
|
||||
token="tok_abc",
|
||||
msg_id="auth-001",
|
||||
app_version="1.0.0",
|
||||
operation_system="Linux",
|
||||
bot_version="0.1.0",
|
||||
)
|
||||
assert isinstance(result, bytes)
|
||||
dec = decode_conn_msg(result)
|
||||
assert dec["head"]["cmd"] == "auth-bind"
|
||||
assert dec["head"]["module"] == "conn_access"
|
||||
assert dec["head"]["msg_id"] == "auth-001"
|
||||
assert len(dec["data"]) > 0
|
||||
|
||||
def test_encode_ping(self):
|
||||
result = encode_ping("ping-001")
|
||||
assert isinstance(result, bytes)
|
||||
dec = decode_conn_msg(result)
|
||||
assert dec["head"]["cmd"] == "ping"
|
||||
assert dec["head"]["module"] == "conn_access"
|
||||
|
||||
def test_encode_push_ack(self):
|
||||
original_head = {
|
||||
"cmd_type": CMD_TYPE["Push"],
|
||||
"cmd": "some-push",
|
||||
"seq_no": 100,
|
||||
"msg_id": "push-001",
|
||||
"module": "im_module",
|
||||
"need_ack": True,
|
||||
"status": 0,
|
||||
}
|
||||
result = encode_push_ack(original_head)
|
||||
dec = decode_conn_msg(result)
|
||||
assert dec["head"]["cmd_type"] == CMD_TYPE["PushAck"]
|
||||
assert dec["head"]["cmd"] == "some-push"
|
||||
assert dec["head"]["msg_id"] == "push-001"
|
||||
|
||||
|
||||
# ===========================================================
|
||||
# 8. 常量验证
|
||||
# ===========================================================
|
||||
|
||||
class TestConstants:
|
||||
def test_pb_msg_types_keys(self):
|
||||
assert "ConnMsg" in PB_MSG_TYPES
|
||||
assert "AuthBindReq" in PB_MSG_TYPES
|
||||
assert "PingReq" in PB_MSG_TYPES
|
||||
assert "KickoutMsg" in PB_MSG_TYPES
|
||||
assert "PushMsg" in PB_MSG_TYPES
|
||||
|
||||
def test_biz_services_keys(self):
|
||||
assert "SendC2CMessageReq" in BIZ_SERVICES
|
||||
assert "SendGroupMessageReq" in BIZ_SERVICES
|
||||
assert "InboundMessagePush" in BIZ_SERVICES
|
||||
|
||||
def test_cmd_type_values(self):
|
||||
assert CMD_TYPE["Request"] == 0
|
||||
assert CMD_TYPE["Response"] == 1
|
||||
assert CMD_TYPE["Push"] == 2
|
||||
assert CMD_TYPE["PushAck"] == 3
|
||||
|
||||
def test_pkg_prefix(self):
|
||||
for k, v in BIZ_SERVICES.items():
|
||||
assert v.startswith("yuanbao_openclaw_proxy"), \
|
||||
f"{k}: unexpected prefix in {v}"
|
||||
|
||||
|
||||
# ===========================================================
|
||||
# 9. seq_no 生成
|
||||
# ===========================================================
|
||||
|
||||
class TestSeqNo:
|
||||
def test_monotonic(self):
|
||||
a = next_seq_no()
|
||||
b = next_seq_no()
|
||||
c = next_seq_no()
|
||||
assert b > a
|
||||
assert c > b
|
||||
|
||||
def test_thread_safety(self):
|
||||
import threading
|
||||
results = []
|
||||
lock = threading.Lock()
|
||||
|
||||
def worker():
|
||||
for _ in range(100):
|
||||
v = next_seq_no()
|
||||
with lock:
|
||||
results.append(v)
|
||||
|
||||
threads = [threading.Thread(target=worker) for _ in range(10)]
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
# 无重复
|
||||
assert len(results) == len(set(results)), "duplicate seq_no detected"
|
||||
|
||||
|
||||
# ===========================================================
|
||||
# 10. 完整端到端流程(模拟 send -> recv)
|
||||
# ===========================================================
|
||||
|
||||
class TestEndToEnd:
|
||||
def test_send_recv_c2c(self):
|
||||
"""模拟发送 C2C 消息,然后(在接收方)解码"""
|
||||
msg_body = [
|
||||
{"msg_type": "TIMTextElem", "msg_content": {"text": "端到端测试"}},
|
||||
]
|
||||
# 发送方编码
|
||||
wire_bytes = encode_send_c2c_message(
|
||||
to_account="recv_user",
|
||||
msg_body=msg_body,
|
||||
from_account="send_bot",
|
||||
msg_id="e2e-001",
|
||||
)
|
||||
# 接收方解码 ConnMsg
|
||||
dec = decode_conn_msg(wire_bytes)
|
||||
assert dec["head"]["cmd"] == "send_c2c_message"
|
||||
assert dec["head"]["msg_id"] == "e2e-001"
|
||||
|
||||
# 从 biz payload 中读取 to_account 和 msg_body
|
||||
from gateway.platforms.yuanbao_proto import (
|
||||
_parse_fields, _fields_to_dict, _get_string, _get_repeated_bytes, WT_LEN
|
||||
)
|
||||
biz = dec["data"]
|
||||
fdict = _fields_to_dict(_parse_fields(biz))
|
||||
assert _get_string(fdict, 2) == "recv_user" # to_account
|
||||
assert _get_string(fdict, 3) == "send_bot" # from_account
|
||||
|
||||
el_list = _get_repeated_bytes(fdict, 5) # msg_body repeated
|
||||
assert len(el_list) == 1
|
||||
el_dec = _decode_msg_body_element(el_list[0])
|
||||
assert el_dec["msg_type"] == "TIMTextElem"
|
||||
assert el_dec["msg_content"]["text"] == "端到端测试"
|
||||
|
||||
def test_inbound_push_full_flow(self):
|
||||
"""构造服务端 push -> 解码入站消息"""
|
||||
from gateway.platforms.yuanbao_proto import (
|
||||
_encode_field, _encode_string, _encode_message,
|
||||
_encode_varint, WT_LEN, WT_VARINT,
|
||||
)
|
||||
# 构造入站消息 biz payload
|
||||
el_bytes = _encode_msg_body_element(
|
||||
{"msg_type": "TIMTextElem", "msg_content": {"text": "server push"}}
|
||||
)
|
||||
biz_payload = (
|
||||
_encode_field(2, WT_LEN, _encode_string("alice"))
|
||||
+ _encode_field(3, WT_LEN, _encode_string("bot"))
|
||||
+ _encode_field(6, WT_LEN, _encode_string("grp-001"))
|
||||
+ _encode_field(8, WT_VARINT, _encode_varint(555))
|
||||
+ _encode_field(11, WT_LEN, _encode_string("msg-key-xyz"))
|
||||
+ _encode_field(13, WT_LEN, _encode_message(el_bytes))
|
||||
)
|
||||
# 封装成 ConnMsg(模拟服务端 push)
|
||||
wire = encode_conn_msg_full(
|
||||
cmd_type=CMD_TYPE["Push"],
|
||||
cmd="/im/new_message",
|
||||
seq_no=77,
|
||||
msg_id="push-abc",
|
||||
module="yuanbao_openclaw_proxy",
|
||||
data=biz_payload,
|
||||
need_ack=True,
|
||||
)
|
||||
# 接收方解码
|
||||
conn = decode_conn_msg(wire)
|
||||
assert conn["head"]["cmd_type"] == CMD_TYPE["Push"]
|
||||
assert conn["head"]["need_ack"] is True
|
||||
|
||||
msg = decode_inbound_push(conn["data"])
|
||||
assert msg is not None
|
||||
assert msg["from_account"] == "alice"
|
||||
assert msg["group_code"] == "grp-001"
|
||||
assert msg["msg_seq"] == 555
|
||||
assert msg["msg_key"] == "msg-key-xyz"
|
||||
assert msg["msg_body"][0]["msg_content"]["text"] == "server push"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -317,6 +317,7 @@ class TestBuiltinDiscovery:
|
||||
"tools.tts_tool",
|
||||
"tools.vision_tools",
|
||||
"tools.web_tools",
|
||||
"tools.yuanbao_tools",
|
||||
}
|
||||
|
||||
with patch("tools.registry.importlib.import_module"):
|
||||
|
||||
Reference in New Issue
Block a user