from __future__ import annotations import pytest from yuxi.channels.adapters.twitch.adapter import TwitchAdapter, _find_utf8_cut from yuxi.channels.adapters.twitch.irc_parser import ParsedIRCMessage from yuxi.channels.adapters.twitch.markdown_utils import strip_twitch_markdown from yuxi.channels.models import ( ChannelIdentity, ChannelResponse, ChannelStatus, ChannelType, DeliveryResult, ) def make_adapter(config=None): cfg = config or {"client_id": "test_id", "access_token": "test_token", "bot_username": "testbot"} return TwitchAdapter(cfg) class TestClassVars: def test_channel_id(self): assert TwitchAdapter.channel_id == "twitch" def test_channel_type(self): assert TwitchAdapter.channel_type == ChannelType.TWITCH def test_text_chunk_limit(self): assert TwitchAdapter.text_chunk_limit == 500 def test_supports_markdown(self): assert TwitchAdapter.supports_markdown is False def test_supports_media(self): assert TwitchAdapter.supports_media is False def test_supports_streaming(self): assert TwitchAdapter.supports_streaming is True def test_streaming_modes(self): assert "off" in TwitchAdapter.streaming_modes assert "block" in TwitchAdapter.streaming_modes class TestInit: def test_initial_status_disconnected(self): adapter = make_adapter() assert adapter._status == ChannelStatus.DISCONNECTED def test_circuit_breaker_initialized(self): adapter = make_adapter() assert adapter._circuit_breaker is not None def test_stores_config(self): cfg = {"client_id": "cid", "access_token": "tok", "bot_username": "bot"} adapter = TwitchAdapter(cfg) assert adapter.config == cfg class TestFormatOutbound: def test_formats_privmsg(self): adapter = make_adapter() identity = ChannelIdentity( channel_id="twitch", channel_type=ChannelType.TWITCH, channel_user_id="", channel_chat_id="#test_channel", ) response = ChannelResponse(identity=identity, content="Hello world") result = adapter.format_outbound(response) assert result["command"] == "PRIVMSG" assert result["target"] == "#test_channel" assert result["text"] == "Hello world" class TestNormalizeInbound: def test_delegates_to_normalizer(self): adapter = make_adapter() parsed = ParsedIRCMessage( command="PRIVMSG", prefix="user!user@host", params=["#test_channel"], trailing="Hello world", tags={"user-id": "123", "display-name": "TestUser", "tmi-sent-ts": "1600000000000"}, raw="", ) msg = adapter.normalize_inbound(parsed) assert msg is not None assert msg.content == "Hello world" assert msg.identity.channel_id == "twitch" def test_returns_none_for_skip_commands(self): adapter = make_adapter() parsed = ParsedIRCMessage( command="JOIN", prefix="user!user@host", params=["#test_channel"], trailing="", tags={}, raw="", ) msg = adapter.normalize_inbound(parsed) assert msg is None class TestSplitIRCText: def test_short_text_no_split(self): chunks = TwitchAdapter._split_irc_text("hello", "#chan") assert len(chunks) == 1 assert chunks[0] == "hello" def test_long_text_splits(self): text = "A" * 600 chunks = TwitchAdapter._split_irc_text(text, "#chan") assert len(chunks) > 1 def test_split_preserves_content(self): text = "This is a test message that should be split properly" chunks = TwitchAdapter._split_irc_text(text, "#chan") assert "".join(chunks) == text def test_split_prefers_newline(self): text = "Line 1\nLine 2\nLine 3" text_long = text * 50 chunks = TwitchAdapter._split_irc_text(text_long, "#chan") for chunk in chunks: assert len(chunk.encode("utf-8")) <= 500 class TestFindUTF8Cut: def test_simple_cut(self): encoded = b"hello world" cut = _find_utf8_cut(encoded, 5) assert 0 < cut <= 5 def test_cut_at_continuation_byte(self): text = "δ½ ε₯½δΈ–η•Œ" encoded = text.encode("utf-8") cut = _find_utf8_cut(encoded, 4) decoded = encoded[:cut].decode("utf-8", errors="replace") assert not any(c == "\ufffd" for c in decoded) def test_cut_zero_becomes_one(self): cut = _find_utf8_cut(b"test", 0) assert cut == 1 class TestReceive: @pytest.mark.asyncio async def test_receive_is_empty_generator(self): adapter = make_adapter() items = [item async for item in adapter.receive()] assert len(items) == 0 class TestSendNotConnected: @pytest.mark.asyncio async def test_send_returns_error_when_not_connected(self): adapter = make_adapter() identity = ChannelIdentity( channel_id="twitch", channel_type=ChannelType.TWITCH, channel_user_id="", channel_chat_id="#test_channel", ) response = ChannelResponse(identity=identity, content="Hello") result = await adapter.send(response) assert isinstance(result, DeliveryResult) assert result.success is False assert result.error == "not_connected" class TestHealthCheckDisconnected: @pytest.mark.asyncio async def test_health_check_reports_unhealthy_when_disconnected(self): adapter = make_adapter() status = await adapter.health_check() assert status.status == "unhealthy" class TestSendStreamChunk: @pytest.mark.asyncio async def test_stream_chunk_returns_error_when_not_connected(self): adapter = make_adapter() result = await adapter.send_stream_chunk("#test", "msg1", "chunk", finished=False) assert isinstance(result, DeliveryResult) assert result.success is False class TestPreConnect: @pytest.mark.asyncio async def test_pre_connect_returns_empty_dict(self): adapter = make_adapter() result = await adapter.pre_connect() assert result == {} class TestBuildStreamIdentity: def test_builds_identity_with_channel_info(self): adapter = make_adapter() identity = adapter._build_stream_identity("#test_channel", "msg_123") assert identity.channel_id == "twitch" assert identity.channel_type == ChannelType.TWITCH assert identity.channel_chat_id == "#test_channel" assert identity.channel_message_id == "msg_123" class TestVerifyWebhookSignature: @pytest.mark.asyncio async def test_returns_true_by_default(self): adapter = make_adapter() result = await adapter.verify_webhook_signature({}, b"") assert result is True class TestFindUTF8CutBounds: def test_byte_limit_within_encoded(self): encoded = b"abc" cut = _find_utf8_cut(encoded, 3) assert cut == 3 def test_cut_at_start_byte(self): text = "\u00e9\u00e0" encoded = text.encode("utf-8") cut = _find_utf8_cut(encoded, 3) decoded = encoded[:cut].decode("utf-8", errors="replace") assert not any(c == "\ufffd" for c in decoded) def test_all_multibyte_characters(self): text = "\u00e9\u00e0\u00fc\u2603" encoded = text.encode("utf-8") for i in range(2, len(encoded) + 1): cut = _find_utf8_cut(encoded, i) decoded = encoded[:cut].decode("utf-8", errors="replace") assert not any(c == "\ufffd" for c in decoded), f"replacement char at byte_limit={i}, cut={cut}" class TestSplitIRCTextEdgeCases: def test_single_character(self): chunks = TwitchAdapter._split_irc_text("A", "#chan") assert len(chunks) == 1 assert chunks[0] == "A" def test_empty_string(self): chunks = TwitchAdapter._split_irc_text("", "#chan") assert len(chunks) == 1 assert chunks[0] == "" def test_very_long_channel_name(self): long_channel = "#" + "x" * 400 text = "Hello world" * 100 chunks = TwitchAdapter._split_irc_text(text, long_channel) for chunk in chunks: assert len(chunk.encode("utf-8")) <= 510 def test_split_with_emojis(self): text = "Hello πŸ˜€πŸŒπŸŽ‰" * 50 chunks = TwitchAdapter._split_irc_text(text, "#chan") for chunk in chunks: assert len(chunk.encode("utf-8")) <= 510 def test_split_cjk_characters(self): text = "δ½ ε₯½δΈ–η•Œ" * 100 chunks = TwitchAdapter._split_irc_text(text, "#chan") assert "".join(chunks) == text class TestDisconnectTaskTracking: def test_reconnect_task_attr_initialized(self): adapter = make_adapter() assert adapter._reconnect_task is None class TestReceiveExplicit: @pytest.mark.asyncio async def test_receive_returns_immediately(self): adapter = make_adapter() items = [item async for item in adapter.receive()] assert items == [] class TestChatTypesCapability: def test_chat_types_is_group(self): assert TwitchAdapter.capabilities.chat_types == ["group"] def test_reply_is_false(self): assert TwitchAdapter.capabilities.reply is False def test_edit_is_false(self): assert TwitchAdapter.capabilities.edit is False def test_reactions_is_false(self): assert TwitchAdapter.capabilities.reactions is False def test_polls_is_false(self): assert TwitchAdapter.capabilities.polls is False def test_block_streaming_is_true(self): assert TwitchAdapter.capabilities.block_streaming is True class TestDeduplicatorInit: def test_deduplicator_initialized(self): adapter = make_adapter() assert adapter._deduplicator is not None def test_deduplicator_empty_initially(self): adapter = make_adapter() assert len(adapter._deduplicator) == 0 class TestMaybeStripMarkdown: def test_strips_markdown_by_default(self): adapter = make_adapter() result = adapter._maybe_strip_markdown("**bold** text") assert "**" not in result assert result == "bold text" def test_no_strip_when_configured(self): adapter = make_adapter({"client_id": "x", "access_token": "x", "bot_username": "x", "strip_markdown": False}) result = adapter._maybe_strip_markdown("**bold** text") assert result == "**bold** text" def test_empty_content_passthrough(self): adapter = make_adapter() assert adapter._maybe_strip_markdown("") == "" class TestSelfMessageFilter: def test_adapter_stores_bot_user_id_none_initially(self): adapter = make_adapter() assert adapter._bot_user_id is None assert adapter._bot_username is None class TestTokenPrefix: def test_config_token_passthrough(self): adapter = make_adapter() assert adapter.config.get("access_token") == "test_token"