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: ") 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("") 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("") 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("") 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( " text " ) 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 World") assert "Hello" in result.text assert "World" in result.text assert "") 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("Some content", 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 == "" 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 == "" 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 == "" 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 "" in tags assert "" 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 " 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