1. 移除Telegram格式化测试中未使用的导入项 2. 修复Teams测试用例,添加monkeypatch参数并配置通配符开关 3. 更新钉钉适配器测试,替换弃用的流属性检查 4. 修正Twitch规范化测试,更新ROOMSTATE测试逻辑 5. 重构会话映射测试,完善数据库执行结果模拟 6. 格式化Slack块构建测试的长参数调用 7. 修复LINE适配器测试,更新能力断言和异步锁使用 8. 修正Slack会话解析测试,修复聊天类型判断错误 9. 更新能力测试,补充缺失的字段检查 10. 修复Matrix适配器测试,修正位置参数和配置校验逻辑 11. 为飞书分析模块测试添加跳过标记 12. 新增微信能力、限流、链接格式、会话路由等模块的单元测试 13. 修复Twitch适配器导入路径和测试断言 14. 新增Discord Webhook、Nextcloud Talk、Signal多账户等模块的单元测试 15. 修复Manager阶段测试的导入路径 16. 新增iMessage异常和命令处理的单元测试 17. 新增Nostr健康检查和相关模块的单元测试 18. 新增Signal守护进程和SSE重连相关测试
1338 lines
47 KiB
Python
1338 lines
47 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import time
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from yuxi.channels.models import ChannelMessage, ChannelIdentity, DeliveryResult
|
|
|
|
|
|
# ============================================================================
|
|
# Test: CommandRegistry (commands/framework.py)
|
|
# ============================================================================
|
|
class TestCommandRegistry:
|
|
@pytest.fixture
|
|
def registry(self):
|
|
from yuxi.channels.adapters.qqbot.commands.framework import CommandRegistry
|
|
|
|
return CommandRegistry()
|
|
|
|
@pytest.fixture
|
|
async def sample_handler(self):
|
|
async def handler(ctx):
|
|
from yuxi.channels.adapters.qqbot.commands.framework import CommandResult
|
|
|
|
return CommandResult(success=True, message=f"Hello {ctx.command_name}")
|
|
|
|
return handler
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_register_and_dispatch(self, registry, sample_handler):
|
|
registry.register("test", sample_handler, description="Test command", usage="/test")
|
|
assert "test" in registry._commands
|
|
|
|
from yuxi.channels.adapters.qqbot.commands.framework import CommandResult
|
|
|
|
result = await registry.dispatch("test", [], "/test", MagicMock(), MagicMock())
|
|
assert isinstance(result, CommandResult)
|
|
assert result.success is True
|
|
assert "Hello test" in result.message
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_register_alias(self, registry, sample_handler):
|
|
registry.register("greet", sample_handler, aliases=["hello", "hi"])
|
|
assert "greet" in registry._commands
|
|
assert "hello" in registry._commands
|
|
assert "hi" in registry._commands
|
|
|
|
result = await registry.dispatch("hello", [], "/hello", MagicMock(), MagicMock())
|
|
assert result.success is True
|
|
|
|
def test_unregister(self, registry, sample_handler):
|
|
async def handler(ctx):
|
|
pass
|
|
|
|
registry.register("rm_test", handler, aliases=["rm_alias"])
|
|
registry.unregister("rm_test")
|
|
assert "rm_test" not in registry._commands
|
|
assert "rm_alias" not in registry._commands
|
|
|
|
def test_unregister_nonexistent(self, registry):
|
|
registry.unregister("nonexistent")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dispatch_unknown_command(self, registry):
|
|
from yuxi.channels.adapters.qqbot.commands.framework import CommandResult
|
|
|
|
result = await registry.dispatch("unknown", [], "/unknown", MagicMock(), MagicMock())
|
|
assert isinstance(result, CommandResult)
|
|
assert result.success is False
|
|
assert "未知命令" in result.message
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dispatch_handler_exception(self, registry):
|
|
async def failing_handler(ctx):
|
|
raise ValueError("Test error")
|
|
|
|
registry.register("fail", failing_handler)
|
|
result = await registry.dispatch("fail", [], "/fail", MagicMock(), MagicMock())
|
|
assert result.success is False
|
|
assert "Test error" in result.message
|
|
|
|
def test_resolve_command(self, registry):
|
|
result = registry.resolve("/hello world")
|
|
assert result == ("hello", ["world"])
|
|
|
|
def test_resolve_command_no_args(self, registry):
|
|
result = registry.resolve("/ping")
|
|
assert result == ("ping", [])
|
|
|
|
def test_resolve_non_command(self, registry):
|
|
result = registry.resolve("hello")
|
|
assert result is None
|
|
|
|
def test_resolve_empty_content(self, registry):
|
|
result = registry.resolve("/")
|
|
assert result is None
|
|
|
|
def test_resolve_only_slash(self, registry):
|
|
result = registry.resolve("/")
|
|
assert result is None
|
|
|
|
def test_command_names(self, registry, sample_handler):
|
|
registry.register("cmd1", sample_handler, aliases=["alias1"])
|
|
registry.register("cmd2", sample_handler)
|
|
|
|
names = registry.command_names
|
|
assert "cmd1" in names
|
|
assert "cmd2" in names
|
|
assert "alias1" not in names or (names.count("cmd1") == 1)
|
|
|
|
def test_register_default_args(self, registry, sample_handler):
|
|
registry.register("simple", sample_handler)
|
|
cmd = registry._commands["simple"]
|
|
assert cmd.description == ""
|
|
assert cmd.usage == ""
|
|
assert cmd.aliases == []
|
|
assert cmd.metadata == {}
|
|
|
|
|
|
# ============================================================================
|
|
# Test: Builtin Commands (commands/builtin.py)
|
|
# ============================================================================
|
|
class TestBuiltinCommands:
|
|
@pytest.fixture
|
|
def registry(self):
|
|
from yuxi.channels.adapters.qqbot.commands.framework import CommandRegistry
|
|
|
|
reg = CommandRegistry()
|
|
from yuxi.channels.adapters.qqbot.commands.builtin import register_builtin_commands
|
|
|
|
register_builtin_commands(reg)
|
|
return reg
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ping(self, registry):
|
|
msg = MagicMock(spec=ChannelMessage)
|
|
msg.identity = MagicMock()
|
|
msg.identity.channel_user_id = "user_001"
|
|
|
|
result = await registry.dispatch("bot-ping", [], "/bot-ping", msg, MagicMock())
|
|
assert result.success is True
|
|
assert "Pong!" in result.message
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ping_alias(self, registry):
|
|
result = await registry.dispatch("ping", [], "/ping", MagicMock(), MagicMock())
|
|
assert result.success is True
|
|
assert "Pong!" in result.message
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_version(self, registry):
|
|
adapter = MagicMock()
|
|
adapter._plugin_version = "2.0.0"
|
|
adapter._bot_info = {"id": "bot_123", "username": "TestBot"}
|
|
|
|
result = await registry.dispatch("bot-version", [], "/bot-version", MagicMock(), adapter)
|
|
assert result.success is True
|
|
assert "2.0.0" in result.message
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_version_no_bot_info(self, registry):
|
|
adapter = MagicMock()
|
|
adapter._plugin_version = "1.0.0"
|
|
adapter._bot_info = None
|
|
|
|
result = await registry.dispatch("bot-version", [], "/bot-version", MagicMock(), adapter)
|
|
assert result.success is True
|
|
assert "1.0.0" in result.message
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_help_with_registry(self, registry):
|
|
adapter = MagicMock()
|
|
adapter._command_registry = registry
|
|
|
|
result = await registry.dispatch("bot-help", [], "/bot-help", MagicMock(), adapter)
|
|
assert result.success is True
|
|
assert "可用命令" in result.message
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_help_no_registry(self, registry):
|
|
adapter = MagicMock()
|
|
adapter._command_registry = None
|
|
|
|
result = await registry.dispatch("bot-help", [], "/bot-help", MagicMock(), adapter)
|
|
assert result.success is True
|
|
assert "未初始化" in result.message
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_status(self, registry):
|
|
adapter = MagicMock()
|
|
adapter.status = "running"
|
|
adapter._reconnect_manager = None
|
|
adapter._circuit_breaker = None
|
|
|
|
result = await registry.dispatch("bot-status", [], "/bot-status", MagicMock(), adapter)
|
|
assert result.success is True
|
|
assert "running" in result.message
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_clearlogs(self, registry):
|
|
adapter = MagicMock()
|
|
adapter._known_users = None
|
|
adapter._group_buffer = None
|
|
|
|
result = await registry.dispatch("bot-clearlogs", [], "/bot-clearlogs", MagicMock(), adapter)
|
|
assert result.success is True
|
|
|
|
|
|
# ============================================================================
|
|
# Test: Streaming Command (commands/streaming_cmd.py)
|
|
# ============================================================================
|
|
class TestStreamingCommand:
|
|
@pytest.fixture
|
|
def registry(self):
|
|
from yuxi.channels.adapters.qqbot.commands.framework import CommandRegistry
|
|
|
|
reg = CommandRegistry()
|
|
from yuxi.channels.adapters.qqbot.commands.streaming_cmd import register_streaming_command
|
|
|
|
register_streaming_command(reg)
|
|
return reg
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_streaming_no_args(self, registry):
|
|
adapter = MagicMock()
|
|
adapter.config = {"streaming_mode": "block"}
|
|
|
|
result = await registry.dispatch("bot-streaming", [], "/bot-streaming", MagicMock(), adapter)
|
|
assert result.success is True
|
|
assert "block" in result.message
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_streaming_status(self, registry):
|
|
adapter = MagicMock()
|
|
adapter.config = {"streaming_mode": "off"}
|
|
|
|
result = await registry.dispatch("bot-streaming", ["status"], "/bot-streaming status", MagicMock(), adapter)
|
|
assert result.success is True
|
|
assert "off" in result.message
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_streaming_on(self, registry):
|
|
adapter = MagicMock()
|
|
adapter.config = {"streaming_mode": "off"}
|
|
adapter.write_config = MagicMock()
|
|
|
|
result = await registry.dispatch("bot-streaming", ["on"], "/bot-streaming on", MagicMock(), adapter)
|
|
assert result.success is True
|
|
assert adapter.config["streaming_mode"] == "block"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_streaming_off(self, registry):
|
|
adapter = MagicMock()
|
|
adapter.config = {"streaming_mode": "block"}
|
|
adapter.write_config = MagicMock()
|
|
|
|
result = await registry.dispatch("bot-streaming", ["off"], "/bot-streaming off", MagicMock(), adapter)
|
|
assert result.success is True
|
|
assert adapter.config["streaming_mode"] == "off"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_streaming_invalid(self, registry):
|
|
adapter = MagicMock()
|
|
adapter.config = {"streaming_mode": "off"}
|
|
|
|
result = await registry.dispatch("bot-streaming", ["invalid"], "/bot-streaming invalid", MagicMock(), adapter)
|
|
assert result.success is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_streaming_block_keyword(self, registry):
|
|
adapter = MagicMock()
|
|
adapter.config = {"streaming_mode": "off"}
|
|
adapter.write_config = MagicMock()
|
|
|
|
result = await registry.dispatch("bot-streaming", ["block"], "/bot-streaming block", MagicMock(), adapter)
|
|
assert result.success is True
|
|
assert adapter.config["streaming_mode"] == "block"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_persist_config_write_error_handled(self, registry):
|
|
adapter = MagicMock()
|
|
adapter.config = {"streaming_mode": "off"}
|
|
|
|
def failing_write(key, value):
|
|
raise OSError("Write failed")
|
|
|
|
adapter.write_config = failing_write
|
|
|
|
result = await registry.dispatch("bot-streaming", ["on"], "/bot-streaming on", MagicMock(), adapter)
|
|
assert result.success is True
|
|
|
|
|
|
# ============================================================================
|
|
# Test: MessageSeqManager (send.py)
|
|
# ============================================================================
|
|
class TestMessageSeqManager:
|
|
@pytest.fixture
|
|
def seq(self):
|
|
from yuxi.channels.adapters.qqbot.send import MessageSeqManager
|
|
|
|
return MessageSeqManager()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_acquire_active_increments(self, seq):
|
|
s1 = await seq.acquire_active()
|
|
s2 = await seq.acquire_active()
|
|
assert s2 == s1 + 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_acquire_passive_decrements(self, seq):
|
|
s1 = await seq.acquire_passive()
|
|
s2 = await seq.acquire_passive()
|
|
assert s2 == s1 - 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_active_seq_wraps_at_max(self, seq):
|
|
seq._active_seq = seq.MAX_SEQ
|
|
s = await seq.acquire_active()
|
|
assert s == seq.MAX_SEQ
|
|
s2 = await seq.acquire_active()
|
|
assert s2 == 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_passive_seq_wraps_at_max(self, seq):
|
|
seq._passive_seq = seq.MAX_SEQ
|
|
s = await seq.acquire_passive()
|
|
assert s == seq.MAX_SEQ
|
|
s2 = await seq.acquire_passive()
|
|
assert s2 == seq.MAX_SEQ - 1
|
|
|
|
def test_reset(self, seq):
|
|
seq._active_seq = 100
|
|
seq._passive_seq = -50
|
|
seq.reset()
|
|
assert seq._active_seq == 1
|
|
assert seq._passive_seq == 0
|
|
|
|
def test_snapshot(self, seq):
|
|
seq._active_seq = 42
|
|
seq._passive_seq = -7
|
|
snap = seq.snapshot()
|
|
assert snap["active_seq"] == 42
|
|
assert snap["passive_seq"] == -7
|
|
|
|
def test_restore(self, seq):
|
|
seq.restore({"active_seq": 99, "passive_seq": -3})
|
|
assert seq._active_seq == 99
|
|
assert seq._passive_seq == -3
|
|
assert seq.next_seq == 99
|
|
|
|
|
|
# ============================================================================
|
|
# Test: resolve_send_url (send.py)
|
|
# ============================================================================
|
|
class TestResolveSendUrl:
|
|
def test_group_chat_prefix(self):
|
|
from yuxi.channels.adapters.qqbot.send import resolve_send_url
|
|
|
|
url = resolve_send_url("https://api.sgroup.qq.com", "group_test_group")
|
|
assert "groups" in url
|
|
assert "test_group" in url
|
|
|
|
def test_dm_chat_prefix(self):
|
|
from yuxi.channels.adapters.qqbot.send import resolve_send_url
|
|
|
|
url = resolve_send_url("https://api.sgroup.qq.com", "dm_test_user")
|
|
assert "users" in url
|
|
assert "test_user" in url
|
|
|
|
def test_channel_id(self):
|
|
from yuxi.channels.adapters.qqbot.send import resolve_send_url
|
|
|
|
url = resolve_send_url("https://api.sgroup.qq.com", "channel_123")
|
|
assert "channels" in url
|
|
assert "channel_123" in url
|
|
|
|
def test_empty_chat_id(self):
|
|
from yuxi.channels.adapters.qqbot.send import resolve_send_url
|
|
|
|
url = resolve_send_url("https://api.sgroup.qq.com", "")
|
|
assert "@me" in url
|
|
|
|
|
|
# ============================================================================
|
|
# Test: render_reply_payload (send.py)
|
|
# ============================================================================
|
|
class TestRenderReplyPayload:
|
|
def test_basic_payload(self):
|
|
from yuxi.channels.adapters.qqbot.send import render_reply_payload
|
|
|
|
payload = render_reply_payload("Hello", msg_type=0)
|
|
assert payload["content"] == "Hello"
|
|
assert payload["msg_type"] == 0
|
|
|
|
def test_with_msg_id(self):
|
|
from yuxi.channels.adapters.qqbot.send import render_reply_payload
|
|
|
|
payload = render_reply_payload("Hello", msg_type=0, msg_id="msg_001")
|
|
assert payload["msg_id"] == "msg_001"
|
|
|
|
def test_chunked_payload(self):
|
|
from yuxi.channels.adapters.qqbot.send import render_reply_payload
|
|
|
|
payload = render_reply_payload(
|
|
"Hello", msg_type=0, chunk_index=0, total_chunks=3
|
|
)
|
|
assert payload["chunk_index"] == 0
|
|
assert payload["total_chunks"] == 3
|
|
|
|
def test_single_chunk_no_chunk_fields(self):
|
|
from yuxi.channels.adapters.qqbot.send import render_reply_payload
|
|
|
|
payload = render_reply_payload("Hello", msg_type=0)
|
|
assert "chunk_index" not in payload
|
|
assert "total_chunks" not in payload
|
|
|
|
|
|
# ============================================================================
|
|
# Test: Payload Builders (format.py)
|
|
# ============================================================================
|
|
class TestBuildTextPayload:
|
|
def test_basic_text(self):
|
|
from yuxi.channels.adapters.qqbot.format import build_text_payload
|
|
|
|
resp = MagicMock()
|
|
resp.content = "Hello"
|
|
resp.identity = MagicMock()
|
|
resp.identity.channel_chat_id = "dm_user"
|
|
resp.reply_to_message_id = ""
|
|
|
|
payload = build_text_payload(resp)
|
|
assert payload["content"] == "Hello"
|
|
assert payload["msg_type"] == 0
|
|
|
|
def test_group_chat(self):
|
|
from yuxi.channels.adapters.qqbot.format import build_text_payload
|
|
|
|
resp = MagicMock()
|
|
resp.content = "Hi"
|
|
resp.identity = MagicMock()
|
|
resp.identity.channel_chat_id = "group_abc"
|
|
resp.reply_to_message_id = ""
|
|
|
|
payload = build_text_payload(resp)
|
|
assert payload["group_openid"] == "abc"
|
|
|
|
def test_with_reply(self):
|
|
from yuxi.channels.adapters.qqbot.format import build_text_payload
|
|
|
|
resp = MagicMock()
|
|
resp.content = "Re: Hello"
|
|
resp.identity = MagicMock()
|
|
resp.identity.channel_chat_id = "dm_user"
|
|
resp.reply_to_message_id = "msg_007"
|
|
|
|
payload = build_text_payload(resp)
|
|
assert payload["msg_id"] == "msg_007"
|
|
|
|
def test_truncation(self):
|
|
from yuxi.channels.adapters.qqbot.format import build_text_payload, TEXT_CONTENT_LIMIT
|
|
|
|
long_text = "x" * (TEXT_CONTENT_LIMIT + 100)
|
|
resp = MagicMock()
|
|
resp.content = long_text
|
|
resp.identity = MagicMock()
|
|
resp.identity.channel_chat_id = "dm_user"
|
|
resp.reply_to_message_id = ""
|
|
|
|
payload = build_text_payload(resp)
|
|
assert len(payload["content"]) == TEXT_CONTENT_LIMIT
|
|
|
|
|
|
class TestBuildMarkdownPayload:
|
|
def test_markdown_content(self):
|
|
from yuxi.channels.adapters.qqbot.format import build_markdown_payload
|
|
|
|
resp = MagicMock()
|
|
resp.content = "**bold**"
|
|
resp.metadata = {}
|
|
|
|
payload = build_markdown_payload(resp)
|
|
assert payload["msg_type"] == 2
|
|
assert payload["markdown"]["content"] == "**bold**"
|
|
|
|
def test_markdown_template(self):
|
|
from yuxi.channels.adapters.qqbot.format import build_markdown_payload
|
|
|
|
resp = MagicMock()
|
|
resp.content = "Content here"
|
|
resp.metadata = {"markdown_template_id": "tpl_001", "title": "My Title"}
|
|
|
|
payload = build_markdown_payload(resp)
|
|
assert payload["msg_type"] == 2
|
|
assert payload["markdown"]["template_id"] == "tpl_001"
|
|
params = payload["markdown"]["params"]
|
|
assert any(p["key"] == "title" for p in params)
|
|
assert any(p["key"] == "content" for p in params)
|
|
|
|
|
|
class TestBuildArkPayload:
|
|
def test_ark_payload(self):
|
|
from yuxi.channels.adapters.qqbot.format import build_ark_payload
|
|
|
|
resp = MagicMock()
|
|
resp.content = ""
|
|
resp.metadata = {"ark_template_id": "ark_001", "ark_data": {"key1": "val1"}}
|
|
|
|
payload = build_ark_payload(resp)
|
|
assert payload["msg_type"] == 3
|
|
assert payload["ark"]["template_id"] == "ark_001"
|
|
|
|
|
|
class TestBuildEmbedPayload:
|
|
def test_embed_payload(self):
|
|
from yuxi.channels.adapters.qqbot.format import build_embed_payload
|
|
|
|
resp = MagicMock()
|
|
resp.content = "Description text"
|
|
resp.metadata = {"embed": {"title": "Embed Title", "prompt": "Click here"}}
|
|
|
|
payload = build_embed_payload(resp)
|
|
assert payload["msg_type"] == 4
|
|
assert payload["embed"]["title"] == "Embed Title"
|
|
assert payload["embed"]["description"] == "Description text"
|
|
|
|
|
|
class TestBuildImagePayload:
|
|
def test_image_payload(self):
|
|
from yuxi.channels.adapters.qqbot.format import build_image_payload
|
|
|
|
resp = MagicMock()
|
|
resp.content = "Image caption"
|
|
resp.identity = MagicMock()
|
|
resp.identity.channel_chat_id = "dm_user"
|
|
|
|
payload = build_image_payload(resp, "file_123")
|
|
assert payload["msg_type"] == 1
|
|
assert payload["file_image"] == "file_123"
|
|
assert payload["content"] == "Image caption"
|
|
|
|
|
|
class TestBuildMediaGenericPayload:
|
|
def test_media_generic_payload(self):
|
|
from yuxi.channels.adapters.qqbot.format import build_media_generic_payload
|
|
|
|
resp = MagicMock()
|
|
resp.content = "Media caption"
|
|
resp.identity = MagicMock()
|
|
resp.identity.channel_chat_id = "dm_user"
|
|
|
|
payload = build_media_generic_payload(resp, "file_abc", msg_type=7)
|
|
assert payload["msg_type"] == 7
|
|
assert payload["media"]["file_info"] == "file_abc"
|
|
|
|
|
|
# ============================================================================
|
|
# Test: MarkdownChunker (format.py)
|
|
# ============================================================================
|
|
class TestMarkdownChunker:
|
|
@pytest.fixture
|
|
def chunker(self):
|
|
from yuxi.channels.adapters.qqbot.format import MarkdownChunker
|
|
|
|
return MarkdownChunker(max_chars=500, chunk_overlap=50)
|
|
|
|
def test_short_text_no_chunk(self):
|
|
from yuxi.channels.adapters.qqbot.format import MarkdownChunker
|
|
|
|
chunker = MarkdownChunker()
|
|
chunks = chunker.chunk("Hello world")
|
|
assert len(chunks) == 1
|
|
assert chunks[0] == "Hello world"
|
|
|
|
def test_long_text_splits(self, chunker):
|
|
text = "Paragraph one.\n\nParagraph two.\n\nParagraph three."
|
|
chunks = chunker.chunk(text)
|
|
assert isinstance(chunks, list)
|
|
assert len(chunks) >= 1
|
|
|
|
def test_code_block_preserved(self):
|
|
from yuxi.channels.adapters.qqbot.format import MarkdownChunker
|
|
|
|
chunker = MarkdownChunker(max_chars=4096)
|
|
text = "Normal text.\n\n```python\nprint('hello')\n```\n\nMore text."
|
|
chunks = chunker.chunk(text)
|
|
combined = "".join(chunks)
|
|
assert "```python" in combined
|
|
|
|
def test_force_split_large(self, chunker):
|
|
long_text = "A" * 1000
|
|
chunks = chunker.chunk(long_text)
|
|
assert len(chunks) > 1
|
|
|
|
def test_empty_text(self, chunker):
|
|
chunks = chunker.chunk("")
|
|
assert len(chunks) == 1
|
|
assert chunks[0] == ""
|
|
|
|
def test_markdown_headers_split(self, chunker):
|
|
text = "# Header 1\nContent under header 1.\n\n## Header 2\nContent under header 2."
|
|
chunks = chunker.chunk(text)
|
|
assert len(chunks) >= 1
|
|
|
|
|
|
# ============================================================================
|
|
# Test: MessageCache (message_cache.py)
|
|
# ============================================================================
|
|
class TestMessageCache:
|
|
@pytest.fixture
|
|
def cache(self):
|
|
from yuxi.channels.adapters.qqbot.message_cache import MessageCache
|
|
|
|
return MessageCache(max_messages=10, ttl_s=3600.0)
|
|
|
|
def test_record_sent(self, cache):
|
|
result = DeliveryResult(success=True, message_id="msg_001")
|
|
msg = cache.record_sent(result, "chat_001", "Hello")
|
|
assert msg is not None
|
|
assert msg.message_id == "msg_001"
|
|
assert msg.status == "sent"
|
|
|
|
def test_record_sent_failure(self, cache):
|
|
result = DeliveryResult(success=False, message_id="msg_002", error="Network error")
|
|
msg = cache.record_sent(result, "chat_001", "Hello")
|
|
assert msg.status == "failed"
|
|
assert msg.error == "Network error"
|
|
|
|
def test_record_sent_no_message_id(self, cache):
|
|
result = DeliveryResult(success=True, message_id="")
|
|
msg = cache.record_sent(result, "chat_001", "Hello")
|
|
assert msg is None
|
|
|
|
def test_get_message(self, cache):
|
|
result = DeliveryResult(success=True, message_id="msg_003")
|
|
cache.record_sent(result, "chat_001", "Test")
|
|
msg = cache.get("msg_003")
|
|
assert msg is not None
|
|
assert msg.chat_id == "chat_001"
|
|
|
|
def test_get_nonexistent(self, cache):
|
|
msg = cache.get("nonexistent")
|
|
assert msg is None
|
|
|
|
def test_get_by_chat(self, cache):
|
|
result1 = DeliveryResult(success=True, message_id="msg_a")
|
|
cache.record_sent(result1, "chat_001", "A")
|
|
result2 = DeliveryResult(success=True, message_id="msg_b")
|
|
cache.record_sent(result2, "chat_001", "B")
|
|
|
|
msgs = cache.get_by_chat("chat_001")
|
|
assert len(msgs) == 2
|
|
|
|
def test_record_update(self, cache):
|
|
result = DeliveryResult(success=True, message_id="msg_004")
|
|
cache.record_sent(result, "chat_001", "Original")
|
|
updated = cache.record_update("msg_004", "Updated")
|
|
assert updated is not None
|
|
assert updated.content == "Updated"
|
|
assert updated.update_count == 1
|
|
|
|
def test_record_update_nonexistent(self, cache):
|
|
updated = cache.record_update("no_such_id", "Content")
|
|
assert updated is None
|
|
|
|
def test_max_messages_eviction(self, cache):
|
|
for i in range(15):
|
|
result = DeliveryResult(success=True, message_id=f"msg_{i:03d}")
|
|
cache.record_sent(result, "chat_001", f"Content {i}")
|
|
snapshot = cache.snapshot
|
|
assert snapshot["total"] <= 10
|
|
|
|
def test_cleanup_expired(self, cache):
|
|
result = DeliveryResult(success=True, message_id="msg_old")
|
|
msg = cache.record_sent(result, "chat_001", "Old")
|
|
if msg:
|
|
msg.sent_at = time.time() - 7200
|
|
|
|
count = cache.cleanup_expired()
|
|
assert count >= 0
|
|
|
|
def test_clear(self, cache):
|
|
result = DeliveryResult(success=True, message_id="msg_001")
|
|
cache.record_sent(result, "chat_001", "Test")
|
|
count = cache.clear()
|
|
assert count == 1
|
|
assert cache.snapshot["total"] == 0
|
|
|
|
def test_snapshot(self, cache):
|
|
result = DeliveryResult(success=True, message_id="msg_001")
|
|
cache.record_sent(result, "chat_001", "Test")
|
|
snap = cache.snapshot
|
|
assert "total" in snap
|
|
assert "max" in snap
|
|
assert "recent" in snap
|
|
|
|
def test_content_truncation(self, cache):
|
|
long_content = "x" * 1000
|
|
result = DeliveryResult(success=True, message_id="msg_long")
|
|
msg = cache.record_sent(result, "chat_001", long_content)
|
|
assert len(msg.content) == 500
|
|
|
|
|
|
# ============================================================================
|
|
# Test: MessageQueue (message_queue.py)
|
|
# ============================================================================
|
|
class TestMessageQueue:
|
|
@pytest.fixture
|
|
def make_msg(self):
|
|
def _make(user_id="user_001", chat_id="dm_user", content="Hello"):
|
|
msg = MagicMock(spec=ChannelMessage)
|
|
msg.identity = MagicMock()
|
|
msg.identity.channel_user_id = user_id
|
|
msg.identity.channel_chat_id = chat_id
|
|
msg.content = content
|
|
return msg
|
|
|
|
return _make
|
|
|
|
@pytest.fixture
|
|
def queue(self):
|
|
from yuxi.channels.adapters.qqbot.message_queue import MessageQueue
|
|
|
|
return MessageQueue(global_limit=10, per_user_limit=5, per_group_limit=5)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_enqueue_dequeue(self, queue, make_msg):
|
|
msg = make_msg()
|
|
result = await queue.enqueue(msg)
|
|
assert result is True
|
|
assert queue.size == 1
|
|
|
|
dequeued = await queue.dequeue()
|
|
assert dequeued is msg
|
|
assert queue.size == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_per_user_limit(self, queue, make_msg):
|
|
queue._per_user_limit = 2
|
|
for _ in range(3):
|
|
await queue.enqueue(make_msg(user_id="user_001"))
|
|
assert queue.size <= 2
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_per_group_limit(self, queue, make_msg):
|
|
queue._per_group_limit = 2
|
|
for _ in range(3):
|
|
await queue.enqueue(make_msg(chat_id="group_abc"))
|
|
assert queue.size <= 2
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_acquire_release(self, queue):
|
|
result = await queue.acquire()
|
|
assert result is True
|
|
queue.release()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_acquire_semaphore(self, queue):
|
|
queue._max_concurrency = 1
|
|
assert await queue.acquire() is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_release_no_acquire(self, queue):
|
|
queue.release()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_drain(self, queue, make_msg):
|
|
await queue.enqueue(make_msg())
|
|
await queue.enqueue(make_msg())
|
|
msgs = await queue.drain()
|
|
assert len(msgs) == 2
|
|
assert queue.size == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_task_done(self, queue, make_msg):
|
|
await queue.enqueue(make_msg())
|
|
await queue.dequeue()
|
|
queue.task_done()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_snapshot(self, queue, make_msg):
|
|
await queue.enqueue(make_msg())
|
|
snap = queue.snapshot
|
|
assert snap["queue_size"] == 1
|
|
assert "per_user_counts" in snap
|
|
assert "per_group_counts" in snap
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_global_limit_overwrite(self, queue, make_msg):
|
|
queue._global_limit = 2
|
|
for i in range(4):
|
|
await queue.enqueue(make_msg(user_id=f"user_{i:03d}"))
|
|
assert queue.size <= 2
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_group_limit_only_group_chats(self, queue, make_msg):
|
|
queue._per_group_limit = 3
|
|
await queue.enqueue(make_msg(chat_id="dm_user"))
|
|
await queue.enqueue(make_msg(chat_id="channel_123"))
|
|
assert queue.size == 2
|
|
|
|
|
|
# ============================================================================
|
|
# Test: TokenBucket (rate_limiter.py)
|
|
# ============================================================================
|
|
class TestTokenBucket:
|
|
@pytest.fixture
|
|
def bucket(self):
|
|
from yuxi.channels.adapters.qqbot.rate_limiter import TokenBucket
|
|
|
|
return TokenBucket(rate=100.0, burst=10)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_initial_tokens(self, bucket):
|
|
assert bucket._tokens == 10.0
|
|
|
|
async def test_try_acquire_sufficient(self, bucket):
|
|
assert bucket.try_acquire(5.0) is True
|
|
assert bucket._tokens <= 5.0
|
|
|
|
def test_try_acquire_insufficient(self, bucket):
|
|
assert bucket.try_acquire(20.0) is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_acquire_waits(self, bucket):
|
|
bucket._tokens = 0
|
|
bucket.rate = 1000.0
|
|
|
|
async def add_tokens():
|
|
await asyncio.sleep(0.05)
|
|
bucket._tokens = 5.0
|
|
|
|
asyncio.create_task(add_tokens())
|
|
await bucket.acquire(1.0)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_refill(self, bucket):
|
|
bucket._tokens = 0
|
|
bucket.rate = 100.0
|
|
await asyncio.sleep(0.02)
|
|
bucket._refill()
|
|
assert bucket._tokens > 0
|
|
|
|
def test_empty_bucket_with_zero_rate(self, bucket):
|
|
bucket._tokens = 0
|
|
bucket.rate = 0.0
|
|
assert bucket.try_acquire(1.0) is False
|
|
|
|
|
|
# ============================================================================
|
|
# Test: RouteRateLimiter (rate_limiter.py)
|
|
# ============================================================================
|
|
class TestRouteRateLimiter:
|
|
@pytest.fixture
|
|
def limiter(self):
|
|
from yuxi.channels.adapters.qqbot.rate_limiter import RouteRateLimiter
|
|
|
|
return RouteRateLimiter()
|
|
|
|
def test_try_acquire_default(self, limiter):
|
|
assert limiter.try_acquire("default", 1.0) is True
|
|
|
|
def test_try_acquire_unknown_route(self, limiter):
|
|
assert limiter.try_acquire("unknown_route", 1.0) is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_acquire_default(self, limiter):
|
|
await limiter.acquire("default", 1.0)
|
|
|
|
def test_get_stats(self, limiter):
|
|
limiter.try_acquire("send_message", 1.0)
|
|
stats = limiter.get_stats()
|
|
assert "send_message" in stats
|
|
|
|
def test_custom_defaults(self):
|
|
from yuxi.channels.adapters.qqbot.rate_limiter import RouteRateLimiter
|
|
|
|
limiter = RouteRateLimiter(defaults={"custom": (100.0, 10), "default": (10.0, 20)})
|
|
result = limiter.try_acquire("custom", 1.0)
|
|
assert result is True
|
|
|
|
def test_default_route(self):
|
|
from yuxi.channels.adapters.qqbot.rate_limiter import RouteRateLimiter
|
|
|
|
limiter = RouteRateLimiter()
|
|
result = limiter.try_acquire("send_message", 1.0)
|
|
assert result is True
|
|
|
|
|
|
# ============================================================================
|
|
# Test: RetryTask & MessageRetryQueue (retry_queue.py)
|
|
# ============================================================================
|
|
class TestRetryTask:
|
|
def test_retry_task_creation(self):
|
|
from yuxi.channels.adapters.qqbot.retry_queue import RetryTask
|
|
|
|
task = RetryTask(
|
|
task_id="t_001",
|
|
chat_id="chat_001",
|
|
payload={"content": "Hello"},
|
|
max_attempts=3,
|
|
)
|
|
assert task.task_id == "t_001"
|
|
assert task.attempt == 0
|
|
assert task.max_attempts == 3
|
|
|
|
|
|
class TestMessageRetryQueue:
|
|
@pytest.fixture
|
|
def retry_queue(self):
|
|
from yuxi.channels.adapters.qqbot.retry_queue import MessageRetryQueue
|
|
|
|
return MessageRetryQueue(max_concurrent=2, poll_interval=0.01)
|
|
|
|
@pytest.fixture
|
|
def make_task(self):
|
|
from yuxi.channels.adapters.qqbot.retry_queue import RetryTask
|
|
|
|
def _make(task_id="t_001", chat_id="chat_001", max_attempts=3):
|
|
return RetryTask(
|
|
task_id=task_id,
|
|
chat_id=chat_id,
|
|
payload={"content": "Hello"},
|
|
max_attempts=max_attempts,
|
|
base_delay=0.01,
|
|
max_delay=0.1,
|
|
)
|
|
|
|
return _make
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_enqueue(self, retry_queue, make_task):
|
|
task = make_task()
|
|
await retry_queue.enqueue(task)
|
|
assert retry_queue.pending_count == 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_enqueue_duplicate_id(self, retry_queue, make_task):
|
|
task = make_task(task_id="dup_001")
|
|
await retry_queue.enqueue(task)
|
|
retry_queue._active_tasks.add("dup_001")
|
|
await retry_queue.enqueue(make_task(task_id="dup_001"))
|
|
assert retry_queue.pending_count == 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_start_stop(self, retry_queue):
|
|
await retry_queue.start()
|
|
assert retry_queue._running is True
|
|
await retry_queue.stop()
|
|
assert retry_queue._running is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_start_twice(self, retry_queue):
|
|
await retry_queue.start()
|
|
await retry_queue.start()
|
|
assert retry_queue._running is True
|
|
await retry_queue.stop()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_worker_processes_task(self, retry_queue, make_task):
|
|
call_count = 0
|
|
|
|
async def send_cb(chat_id, payload):
|
|
nonlocal call_count
|
|
call_count += 1
|
|
return True
|
|
|
|
retry_queue.set_send_callback(send_cb)
|
|
task = make_task()
|
|
task.next_retry_at = 0
|
|
await retry_queue.enqueue(task)
|
|
await retry_queue.start()
|
|
await asyncio.sleep(0.05)
|
|
await retry_queue.stop()
|
|
assert call_count >= 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_dead_letter_on_exhausted(self, retry_queue, make_task):
|
|
async def send_cb(chat_id, payload):
|
|
return False
|
|
|
|
retry_queue.set_send_callback(send_cb)
|
|
task = make_task(task_id="exhaust_001", max_attempts=1)
|
|
task.next_retry_at = 0
|
|
await retry_queue.enqueue(task)
|
|
await retry_queue.start()
|
|
await asyncio.sleep(0.1)
|
|
await retry_queue.stop()
|
|
assert retry_queue.dead_count >= 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_clear_dead_letter(self, retry_queue, make_task):
|
|
task = make_task(task_id="dead_001")
|
|
retry_queue._dead_letter.append(task)
|
|
count = retry_queue.clear_dead_letter()
|
|
assert count == 1
|
|
assert retry_queue.dead_count == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_dead_letter_tasks(self, retry_queue, make_task):
|
|
task = make_task()
|
|
retry_queue._dead_letter.append(task)
|
|
tasks = retry_queue.get_dead_letter_tasks()
|
|
assert len(tasks) == 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_send_callback(self, retry_queue, make_task):
|
|
task = make_task()
|
|
task.next_retry_at = 0
|
|
await retry_queue.enqueue(task)
|
|
await retry_queue.start()
|
|
await asyncio.sleep(0.05)
|
|
await retry_queue.stop()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_send_callback_raises(self, retry_queue, make_task):
|
|
async def raising_cb(chat_id, payload):
|
|
raise RuntimeError("Send failed")
|
|
|
|
retry_queue.set_send_callback(raising_cb)
|
|
task = make_task()
|
|
task.next_retry_at = 0
|
|
await retry_queue.enqueue(task)
|
|
await retry_queue.start()
|
|
await asyncio.sleep(0.05)
|
|
await retry_queue.stop()
|
|
|
|
|
|
# ============================================================================
|
|
# Test: Media Tags (media_tags.py)
|
|
# ============================================================================
|
|
class TestParseMediaTags:
|
|
def test_parse_qqmedia_image(self):
|
|
from yuxi.channels.adapters.qqbot.media_tags import parse_media_tags
|
|
|
|
result = parse_media_tags("Check this: <qqmedia:image:file_123>")
|
|
assert len(result.media_items) == 1
|
|
assert result.media_items[0].media_type == "image"
|
|
assert result.media_items[0].reference == "file_123"
|
|
|
|
def test_parse_qqmedia_voice(self):
|
|
from yuxi.channels.adapters.qqbot.media_tags import parse_media_tags
|
|
|
|
result = parse_media_tags("<qqmedia:voice:audio_456>")
|
|
assert result.media_items[0].media_type == "voice"
|
|
assert result.media_items[0].reference == "audio_456"
|
|
|
|
def test_parse_qqimg(self):
|
|
from yuxi.channels.adapters.qqbot.media_tags import parse_media_tags
|
|
|
|
result = parse_media_tags("<qqimg:https://example.com/img.png>")
|
|
assert result.media_items[0].media_type == "image"
|
|
assert result.media_items[0].is_url is True
|
|
|
|
def test_parse_qqvideo(self):
|
|
from yuxi.channels.adapters.qqbot.media_tags import parse_media_tags
|
|
|
|
result = parse_media_tags("<qqvideo:https://example.com/vid.mp4>")
|
|
assert result.media_items[0].media_type == "video"
|
|
assert result.media_items[0].is_url is True
|
|
|
|
def test_parse_multiple_media(self):
|
|
from yuxi.channels.adapters.qqbot.media_tags import parse_media_tags
|
|
|
|
result = parse_media_tags(
|
|
"<qqmedia:image:f1> text <qqmedia:video:f2>"
|
|
)
|
|
assert len(result.media_items) == 2
|
|
|
|
def test_clean_text_from_tags(self):
|
|
from yuxi.channels.adapters.qqbot.media_tags import parse_media_tags
|
|
|
|
result = parse_media_tags("Hello <qqmedia:image:f1> World")
|
|
assert "Hello" in result.text
|
|
assert "World" in result.text
|
|
assert "<qqmedia" not in result.text
|
|
|
|
def test_has_media_tags_positive(self):
|
|
from yuxi.channels.adapters.qqbot.media_tags import has_media_tags
|
|
|
|
assert has_media_tags("<qqmedia:image:file_123>") is True
|
|
|
|
def test_has_media_tags_negative(self):
|
|
from yuxi.channels.adapters.qqbot.media_tags import has_media_tags
|
|
|
|
assert has_media_tags("Plain text") is False
|
|
|
|
def test_has_media_tags_empty(self):
|
|
from yuxi.channels.adapters.qqbot.media_tags import has_media_tags
|
|
|
|
assert has_media_tags("") is False
|
|
|
|
def test_parsed_content_has_media(self):
|
|
from yuxi.channels.adapters.qqbot.media_tags import ParsedContent, InlineMedia
|
|
|
|
pc = ParsedContent(text="", media_items=[])
|
|
assert pc.has_media is False
|
|
|
|
pc.media_items.append(InlineMedia(media_type="image", reference="f1"))
|
|
assert pc.has_media is True
|
|
|
|
|
|
class TestParseSizeTag:
|
|
def test_parse_size_tag(self):
|
|
from yuxi.channels.adapters.qqbot.media_tags import parse_size_tag
|
|
|
|
result = parse_size_tag("<qqsize:1000>Some content</qqsize>", default_limit=2000)
|
|
assert result.text == "Some content"
|
|
assert result.size_limit == 1000
|
|
|
|
def test_parse_size_tag_no_tag(self):
|
|
from yuxi.channels.adapters.qqbot.media_tags import parse_size_tag
|
|
|
|
result = parse_size_tag("Plain text", default_limit=2000)
|
|
assert result.text == "Plain text"
|
|
assert result.size_limit == 2000
|
|
|
|
|
|
class TestBuildMediaTag:
|
|
def test_build_media_tag_image_url(self):
|
|
from yuxi.channels.adapters.qqbot.media_tags import build_media_tag
|
|
|
|
tag = build_media_tag("image", "https://example.com/img.png", is_url=True)
|
|
assert tag == "<qqimg:https://example.com/img.png>"
|
|
|
|
def test_build_media_tag_video_url(self):
|
|
from yuxi.channels.adapters.qqbot.media_tags import build_media_tag
|
|
|
|
tag = build_media_tag("video", "https://example.com/vid.mp4", is_url=True)
|
|
assert tag == "<qqvideo:https://example.com/vid.mp4>"
|
|
|
|
def test_build_media_tag_file(self):
|
|
from yuxi.channels.adapters.qqbot.media_tags import build_media_tag
|
|
|
|
tag = build_media_tag("file", "file_123", is_url=False)
|
|
assert tag == "<qqmedia:file:file_123>"
|
|
|
|
def test_build_media_tags_list(self):
|
|
from yuxi.channels.adapters.qqbot.media_tags import build_media_tags, InlineMedia
|
|
|
|
items = [
|
|
InlineMedia(media_type="image", reference="f1"),
|
|
InlineMedia(media_type="voice", reference="v1"),
|
|
]
|
|
tags = build_media_tags(items)
|
|
assert "<qqmedia:image:f1>" in tags
|
|
assert "<qqmedia:voice:v1>" in tags
|
|
|
|
|
|
class TestEmbedMediaInText:
|
|
def test_embed_media(self):
|
|
from yuxi.channels.adapters.qqbot.media_tags import embed_media_in_text, InlineMedia
|
|
|
|
items = [InlineMedia(media_type="image", reference="f1")]
|
|
result = embed_media_in_text("Hello", items)
|
|
assert "Hello" in result
|
|
assert "<qqmedia" in result
|
|
|
|
def test_embed_no_media(self):
|
|
from yuxi.channels.adapters.qqbot.media_tags import embed_media_in_text
|
|
|
|
result = embed_media_in_text("Hello", [])
|
|
assert result == "Hello"
|
|
|
|
|
|
# ============================================================================
|
|
# Test: InboundPipeline (inbound_pipeline.py)
|
|
# ============================================================================
|
|
class TestPipelineContext:
|
|
def test_pipeline_context_creation(self):
|
|
from yuxi.channels.adapters.qqbot.inbound_pipeline import PipelineContext
|
|
|
|
ctx = PipelineContext()
|
|
assert ctx.stopped is False
|
|
|
|
def test_stop_sets_reason(self):
|
|
from yuxi.channels.adapters.qqbot.inbound_pipeline import PipelineContext
|
|
|
|
ctx = PipelineContext()
|
|
ctx.stop("security_block")
|
|
assert ctx.stopped is True
|
|
|
|
def test_stop_only_once_effective(self):
|
|
from yuxi.channels.adapters.qqbot.inbound_pipeline import PipelineContext
|
|
|
|
ctx = PipelineContext()
|
|
ctx.stop("first")
|
|
ctx.stop("second")
|
|
assert ctx.stopped is True
|
|
|
|
def test_debug_summary(self):
|
|
from yuxi.channels.adapters.qqbot.inbound_pipeline import PipelineContext
|
|
|
|
ctx = PipelineContext()
|
|
ctx.stop("test_reason")
|
|
summary = ctx.debug_summary()
|
|
assert isinstance(summary, str)
|
|
|
|
|
|
# ============================================================================
|
|
# Test: KnownUserTracker edge cases (more thorough)
|
|
# ============================================================================
|
|
class TestKnownUserTrackerEdgeCases:
|
|
@pytest.fixture
|
|
def tmp_persist_dir(self, tmp_path):
|
|
return str(tmp_path)
|
|
|
|
def test_get_recent_users_returns_max_n(self, tmp_persist_dir):
|
|
from yuxi.channels.adapters.qqbot.known_users import KnownUserTracker
|
|
|
|
tracker = KnownUserTracker("test_app", persist_dir=tmp_persist_dir, max_users=10)
|
|
for i in range(20):
|
|
tracker.record(f"user_{i:03d}", f"Name_{i}")
|
|
recent = tracker.get_recent_users(5)
|
|
assert len(recent) <= 5
|
|
|
|
def test_clear_resets_properties(self, tmp_persist_dir):
|
|
from yuxi.channels.adapters.qqbot.known_users import KnownUserTracker
|
|
|
|
tracker = KnownUserTracker("test_app", persist_dir=tmp_persist_dir)
|
|
tracker.record("user_a", "Alice")
|
|
tracker.clear()
|
|
assert tracker.count == 0
|
|
assert tracker.is_known("user_a") is False
|
|
|
|
|
|
# ============================================================================
|
|
# Test: ConfigHints (config_hints.py)
|
|
# ============================================================================
|
|
class TestConfigHints:
|
|
def test_import_config_hints(self):
|
|
from yuxi.channels.adapters.qqbot import config_hints
|
|
|
|
assert config_hints is not None
|
|
|
|
def test_get_config_hints(self):
|
|
from yuxi.channels.adapters.qqbot.config_hints import get_config_hints
|
|
|
|
hints = get_config_hints()
|
|
assert isinstance(hints, list)
|
|
assert len(hints) > 0
|
|
|
|
|
|
# ============================================================================
|
|
# Test: Constants (constants.py)
|
|
# ============================================================================
|
|
class TestConstants:
|
|
def test_chat_prefixes(self):
|
|
from yuxi.channels.adapters.qqbot.constants import GROUP_CHAT_PREFIX, DM_CHAT_PREFIX
|
|
|
|
assert GROUP_CHAT_PREFIX.startswith("group_")
|
|
assert DM_CHAT_PREFIX.startswith("dm_")
|
|
|
|
def test_message_limits(self):
|
|
from yuxi.channels.adapters.qqbot.format import (
|
|
TEXT_CONTENT_LIMIT,
|
|
MARKDOWN_CONTENT_LIMIT,
|
|
)
|
|
|
|
assert TEXT_CONTENT_LIMIT > 0
|
|
assert MARKDOWN_CONTENT_LIMIT > 0
|
|
|
|
def test_qqbot_events(self):
|
|
from yuxi.channels.adapters.qqbot.constants import EventType
|
|
|
|
assert hasattr(EventType, "AT_MESSAGE_CREATE")
|
|
assert hasattr(EventType, "DIRECT_MESSAGE_CREATE")
|
|
assert hasattr(EventType, "MESSAGE_CREATE")
|
|
|
|
|
|
# ============================================================================
|
|
# Test: Send with retry edge cases (send.py)
|
|
# ============================================================================
|
|
class TestSendWithRetryEdgeCases:
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_send_url_dm_starts_with(self):
|
|
from yuxi.channels.adapters.qqbot.send import resolve_send_url
|
|
|
|
url = resolve_send_url("https://api.sgroup.qq.com", "dm_user_123")
|
|
assert "users" in url
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_send_url_group_starts_with(self):
|
|
from yuxi.channels.adapters.qqbot.send import resolve_send_url
|
|
|
|
url = resolve_send_url("https://api.sgroup.qq.com", "group_test")
|
|
assert "groups" in url
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_send_url_channel(self):
|
|
from yuxi.channels.adapters.qqbot.send import resolve_send_url
|
|
|
|
url = resolve_send_url("https://api.sgroup.qq.com", "ch_abc")
|
|
assert "channels" in url
|
|
|
|
|
|
# ============================================================================
|
|
# Test: SessionStore additional (session_store.py)
|
|
# ============================================================================
|
|
class TestSessionStoreAdditional:
|
|
@pytest.fixture
|
|
def tmp_dir(self, tmp_path):
|
|
return str(tmp_path)
|
|
|
|
def test_create_session_store(self, tmp_dir):
|
|
from yuxi.channels.adapters.qqbot.session_store import SessionStore
|
|
|
|
store = SessionStore("app_test", store_dir=tmp_dir)
|
|
assert store is not None
|
|
|
|
|
|
# ============================================================================
|
|
# Test: Reference types (ref/types.py)
|
|
# ============================================================================
|
|
class TestRefTypes:
|
|
def test_ref_item(self):
|
|
from yuxi.channels.adapters.qqbot.ref.types import RefItem, RefCategory
|
|
|
|
ref = RefItem(
|
|
ref_id="ref_001",
|
|
category=RefCategory.MSG,
|
|
value="Hello world",
|
|
label="msg_001",
|
|
chat_id="chat_001",
|
|
user_id="user_001",
|
|
)
|
|
assert ref.category == RefCategory.MSG
|
|
assert ref.label == "msg_001"
|
|
|
|
def test_ref_index_create(self):
|
|
from yuxi.channels.adapters.qqbot.ref.store import RefIndex
|
|
|
|
idx = RefIndex()
|
|
assert idx is not None
|
|
|
|
|