from __future__ import annotations import time from datetime import datetime import pytest from yuxi.channels.models import ChannelIdentity, ChannelMessage, ChannelType, ChatType from yuxi.channels.policy.context_policy import ContextCommand, ContextPolicy from yuxi.channels.policy.dedup_policy import DedupPolicy from yuxi.channels.policy.group_chat_policy import GroupChatMode, GroupChatPolicy from yuxi.channels.policy.media_policy import MediaPolicy from yuxi.channels.policy.schedule_policy import ScheduleConfig, SchedulePolicy from yuxi.channels.policy.voice_policy import STTProvider, TTSProvider, VoicePolicy, VoiceSegment from yuxi.channels.policy.welcome_policy import WelcomePolicy def _make_message( channel_id: str = "telegram", channel_chat_id: str = "chat-001", channel_message_id: str = "msg-001", content: str = "你好", chat_type: ChatType = ChatType.DIRECT, ) -> ChannelMessage: return ChannelMessage( identity=ChannelIdentity( channel_id=channel_id, channel_type=ChannelType.TELEGRAM, channel_user_id="user-001", channel_chat_id=channel_chat_id, channel_message_id=channel_message_id, ), content=content, chat_type=chat_type, ) class TestDedupPolicy: @pytest.mark.asyncio async def test_normal_message_not_blocked(self): policy = DedupPolicy() msg = _make_message(channel_message_id="msg-001") assert await policy.is_duplicate(msg) is False @pytest.mark.asyncio async def test_duplicate_message_blocked(self): policy = DedupPolicy() msg = _make_message(channel_message_id="msg-001") assert await policy.is_duplicate(msg) is False assert await policy.is_duplicate(msg) is True @pytest.mark.asyncio async def test_different_channel_same_msg_id_not_blocked(self): policy = DedupPolicy() msg1 = _make_message(channel_id="telegram", channel_message_id="msg-001") msg2 = _make_message(channel_id="discord", channel_message_id="msg-001") assert await policy.is_duplicate(msg1) is False assert await policy.is_duplicate(msg2) is False @pytest.mark.asyncio async def test_no_message_id_not_blocked(self): policy = DedupPolicy() msg = _make_message(channel_message_id=None) assert await policy.is_duplicate(msg) is False @pytest.mark.asyncio async def test_clear_resets_cache(self): policy = DedupPolicy() msg = _make_message(channel_message_id="msg-001") await policy.is_duplicate(msg) await policy.clear() assert await policy.is_duplicate(msg) is False @pytest.mark.asyncio async def test_ttl_expiry(self): policy = DedupPolicy(ttl=1, maxsize=100) msg = _make_message(channel_message_id="msg-001") assert await policy.is_duplicate(msg) is False time.sleep(1.5) assert await policy.is_duplicate(msg) is False class TestContextPolicy: def test_normal_message_not_handled(self): policy = ContextPolicy() msg = _make_message(content="你好") result = policy.parse(msg) assert result.handled is False def test_reset_command(self): policy = ContextPolicy() msg = _make_message(content="/reset") result = policy.parse(msg) assert result.handled is True assert result.command == ContextCommand.RESET assert result.args == "" def test_history_with_args(self): policy = ContextPolicy() msg = _make_message(content="/history 3") result = policy.parse(msg) assert result.handled is True assert result.command == ContextCommand.HISTORY assert result.args == "3" def test_context_command(self): policy = ContextPolicy() msg = _make_message(content="/context") result = policy.parse(msg) assert result.handled is True assert result.command == ContextCommand.CONTEXT def test_summary_command(self): policy = ContextPolicy() msg = _make_message(content="/summary") result = policy.parse(msg) assert result.handled is True assert result.command == ContextCommand.SUMMARY def test_unknown_command_ignored(self): policy = ContextPolicy() msg = _make_message(content="/unknown") result = policy.parse(msg) assert result.handled is False assert result.is_unknown_command is True def test_command_case_insensitive(self): policy = ContextPolicy() msg = _make_message(content="/RESET") result = policy.parse(msg) assert result.handled is True assert result.command == ContextCommand.RESET def test_command_with_extra_spaces(self): policy = ContextPolicy() msg = _make_message(content=" /reset ") result = policy.parse(msg) assert result.handled is True assert result.command == ContextCommand.RESET class TestGroupChatPolicy: def test_direct_chat_always_responds(self): policy = GroupChatPolicy() msg = _make_message(chat_type=ChatType.DIRECT) assert policy.should_respond(msg, is_at_bot=False) is True def test_mention_only_with_at(self): policy = GroupChatPolicy() msg = _make_message(chat_type=ChatType.GROUP) assert policy.should_respond(msg, is_at_bot=True) is True def test_mention_only_without_at(self): policy = GroupChatPolicy() msg = _make_message(chat_type=ChatType.GROUP) assert policy.should_respond(msg, is_at_bot=False) is False def test_all_mode(self): policy = GroupChatPolicy() policy.configure(mode=GroupChatMode.ALL) msg = _make_message(chat_type=ChatType.GROUP) assert policy.should_respond(msg, is_at_bot=False) is True def test_whitelist_hit(self): policy = GroupChatPolicy() policy.configure(mode=GroupChatMode.WHITELIST, whitelist=["chat-001"]) msg = _make_message(chat_type=ChatType.GROUP, channel_chat_id="chat-001") assert policy.should_respond(msg, is_at_bot=False) is True def test_whitelist_miss(self): policy = GroupChatPolicy() policy.configure(mode=GroupChatMode.WHITELIST, whitelist=["chat-002"]) msg = _make_message(chat_type=ChatType.GROUP, channel_chat_id="chat-001") assert policy.should_respond(msg, is_at_bot=False) is False def test_blacklist_hit(self): policy = GroupChatPolicy() policy.configure(mode=GroupChatMode.BLACKLIST, blacklist=["chat-001"]) msg = _make_message(chat_type=ChatType.GROUP, channel_chat_id="chat-001") assert policy.should_respond(msg, is_at_bot=False) is False def test_blacklist_miss(self): policy = GroupChatPolicy() policy.configure(mode=GroupChatMode.BLACKLIST, blacklist=["chat-002"]) msg = _make_message(chat_type=ChatType.GROUP, channel_chat_id="chat-001") assert policy.should_respond(msg, is_at_bot=False) is True def test_clear_whitelist_with_empty_list(self): policy = GroupChatPolicy() policy.configure(mode=GroupChatMode.WHITELIST, whitelist=["chat-001"]) msg = _make_message(chat_type=ChatType.GROUP, channel_chat_id="chat-001") assert policy.should_respond(msg, is_at_bot=False) is True policy.configure(mode=GroupChatMode.WHITELIST, whitelist=[]) assert policy.should_respond(msg, is_at_bot=False) is False class TestWelcomePolicy: def test_new_user_triggers_welcome(self): policy = WelcomePolicy() assert policy.mark_welcomed("user-001") is True def test_existing_user_not_triggered(self): policy = WelcomePolicy() policy.mark_welcomed("user-001") assert policy.mark_welcomed("user-001") is False def test_default_welcome_message(self): policy = WelcomePolicy() assert policy.get_welcome_message() == WelcomePolicy.DEFAULT_WELCOME_MESSAGE def test_custom_welcome_message(self): policy = WelcomePolicy() policy.configure(message_template="自定义欢迎语") assert policy.get_welcome_message() == "自定义欢迎语" class TestSchedulePolicy: def test_is_working_hours(self): policy = SchedulePolicy() dt = datetime(2026, 5, 8, 2, 0, 0) assert policy.is_working_hours(now=dt) is True def test_after_hours(self): policy = SchedulePolicy() dt = datetime(2026, 5, 8, 12, 0, 0) assert policy.is_working_hours(now=dt) is False def test_weekend(self): policy = SchedulePolicy() dt = datetime(2026, 5, 9, 2, 0, 0) assert policy.is_working_hours(now=dt) is False def test_custom_timezone(self): config = ScheduleConfig(timezone_offset_hours=0) policy = SchedulePolicy() policy.configure(config) dt = datetime(2026, 5, 8, 10, 0, 0) assert policy.is_working_hours(now=dt) is True def test_off_hours_reply_default(self): policy = SchedulePolicy() dt = datetime(2026, 5, 8, 12, 0, 0) reply = policy.get_off_hours_reply(now=dt) assert reply == SchedulePolicy.DEFAULT_OFF_HOURS_REPLY def test_working_hours_no_reply(self): policy = SchedulePolicy() dt = datetime(2026, 5, 8, 2, 0, 0) assert policy.get_off_hours_reply(now=dt) is None def test_custom_off_hours_reply(self): config = ScheduleConfig(off_hours_reply="休息中") policy = SchedulePolicy() policy.configure(config) dt = datetime(2026, 5, 8, 12, 0, 0) assert policy.get_off_hours_reply(now=dt) == "休息中" def test_overnight_window(self): from datetime import time from yuxi.channels.policy.schedule_policy import TimeWindow config = ScheduleConfig( work_hours=TimeWindow(start=time(22, 0), end=time(6, 0)), ) policy = SchedulePolicy() policy.configure(config) dt_evening = datetime(2026, 5, 8, 14, 0, 0) assert policy.is_working_hours(now=dt_evening) is True dt_midnight = datetime(2026, 5, 8, 15, 0, 0) assert policy.is_working_hours(now=dt_midnight) is True dt_daytime = datetime(2026, 5, 8, 4, 0, 0) assert policy.is_working_hours(now=dt_daytime) is False class TestMediaPolicy: def test_detect_png(self): policy = MediaPolicy() data = b"\x89PNG\r\n\x1a\n" + b"\x00" * 100 assert policy.detect_format(data) == "png" def test_detect_jpeg(self): policy = MediaPolicy() data = b"\xff\xd8\xff\xe0" + b"\x00" * 100 assert policy.detect_format(data) == "jpg" def test_detect_gif87a(self): policy = MediaPolicy() data = b"GIF87a" + b"\x00" * 100 assert policy.detect_format(data) == "gif" def test_detect_gif89a(self): policy = MediaPolicy() data = b"GIF89a" + b"\x00" * 100 assert policy.detect_format(data) == "gif" def test_detect_webp(self): policy = MediaPolicy() data = b"RIFF\x00\x00\x00\x00WEBP" + b"\x00" * 100 assert policy.detect_format(data) == "webp" def test_detect_unknown(self): policy = MediaPolicy() data = b"\x00\x00\x00\x00" + b"\x00" * 100 assert policy.detect_format(data) is None def test_detect_bmp(self): policy = MediaPolicy() data = b"BM" + b"\x00" * 100 assert policy.detect_format(data) == "bmp" def test_is_supported_format(self): policy = MediaPolicy() assert policy.is_supported_format("png") is True assert policy.is_supported_format("bmp") is True assert policy.is_supported_format("tiff") is False def test_validate_size_ok(self): policy = MediaPolicy() data = b"x" * (5 * 1024 * 1024) assert policy.validate_size(data, max_size_mb=10) is True def test_validate_size_exceeded(self): policy = MediaPolicy() data = b"x" * (50 * 1024 * 1024) assert policy.validate_size(data, max_size_mb=10) is False @pytest.mark.asyncio async def test_cache_url_content_invalid_url(self): policy = MediaPolicy() result = await policy.cache_url_content("http://invalid.test.local/notfound") assert result is None class TestVoicePolicy: @pytest.mark.asyncio async def test_process_voice_no_stt_returns_none(self): policy = VoicePolicy() result = await policy.process_voice(VoiceSegment(audio_data=b"\x00" * 100)) assert result is None @pytest.mark.asyncio async def test_text_to_speech_no_tts_returns_none(self): policy = VoicePolicy() result = await policy.text_to_speech("hello") assert result is None @pytest.mark.asyncio async def test_process_voice_stt_exception_returns_none(self): class FailingSTT(STTProvider): async def transcribe(self, segment: VoiceSegment): raise RuntimeError("STT service unavailable") policy = VoicePolicy() policy.configure_stt(FailingSTT()) result = await policy.process_voice(VoiceSegment(audio_data=b"\x00" * 100)) assert result is None @pytest.mark.asyncio async def test_text_to_speech_tts_exception_returns_none(self): class FailingTTS(TTSProvider): async def synthesize(self, text: str, voice: str = "default"): raise RuntimeError("TTS service unavailable") policy = VoicePolicy() policy.configure_tts(FailingTTS()) result = await policy.text_to_speech("hello") assert result is None class TestPackageImports: def test_import_all_policies(self): from yuxi.channels.policy import ( ApprovalAction, ApprovalConfig, ApprovalRequest, ApprovalState, BaseApprovalCapability, BaseDirectoryAdapter, ContextPolicy, DedupPolicy, GroupChatPolicy, MediaPolicy, QueueDebounce, SchedulePolicy, VoicePolicy, WelcomePolicy, ) assert DedupPolicy is not None assert ContextPolicy is not None assert GroupChatPolicy is not None assert WelcomePolicy is not None assert SchedulePolicy is not None assert MediaPolicy is not None assert VoicePolicy is not None assert ApprovalAction is not None assert BaseApprovalCapability is not None assert QueueDebounce is not None assert BaseDirectoryAdapter is not None def test_import_voice_components(self): from yuxi.channels.policy.voice_policy import STTProvider, TTSProvider assert STTProvider is not None assert TTSProvider is not None