ForcePilot/backend/test/unit/channels/test_twitch_adapter.py
Kris 69fe97a90d test: 批量修复并新增单元测试用例
1. 移除Telegram格式化测试中未使用的导入项
2. 修复Teams测试用例,添加monkeypatch参数并配置通配符开关
3. 更新钉钉适配器测试,替换弃用的流属性检查
4. 修正Twitch规范化测试,更新ROOMSTATE测试逻辑
5. 重构会话映射测试,完善数据库执行结果模拟
6. 格式化Slack块构建测试的长参数调用
7. 修复LINE适配器测试,更新能力断言和异步锁使用
8. 修正Slack会话解析测试,修复聊天类型判断错误
9. 更新能力测试,补充缺失的字段检查
10. 修复Matrix适配器测试,修正位置参数和配置校验逻辑
11. 为飞书分析模块测试添加跳过标记
12. 新增微信能力、限流、链接格式、会话路由等模块的单元测试
13. 修复Twitch适配器导入路径和测试断言
14. 新增Discord Webhook、Nextcloud Talk、Signal多账户等模块的单元测试
15. 修复Manager阶段测试的导入路径
16. 新增iMessage异常和命令处理的单元测试
17. 新增Nostr健康检查和相关模块的单元测试
18. 新增Signal守护进程和SSE重连相关测试
2026-05-13 16:43:01 +08:00

340 lines
11 KiB
Python

from __future__ import annotations
import pytest
from yuxi.channels.adapters.twitch.adapter import TwitchAdapter
from yuxi.channels.adapters.twitch.irc_parser import ParsedIRCMessage
from yuxi.channels.adapters.twitch.send import find_utf8_cut
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 "group" in TwitchAdapter.capabilities.chat_types
def test_reply_is_true(self):
assert TwitchAdapter.capabilities.reply is True
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"