ForcePilot/backend/test/unit/channels/test_qqbot_modules.py

1338 lines
47 KiB
Python
Raw Normal View History

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