Files
AstrBot/tests_v4/test_transport.py

532 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Tests for runtime/transport.py - Transport implementations.
"""
from __future__ import annotations
import asyncio
from io import StringIO
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from astrbot_sdk.runtime.transport import (
StdioTransport,
Transport,
WebSocketClientTransport,
WebSocketServerTransport,
)
class TestTransportBase:
"""Tests for Transport base class."""
def test_init_sets_handler_none(self):
"""Transport should initialize with _handler as None."""
# 创建一个具体的测试子类
class ConcreteTransport(Transport):
async def start(self):
pass
async def stop(self):
pass
async def send(self, message: str):
pass
transport = ConcreteTransport()
assert transport._handler is None
def test_set_message_handler(self):
"""set_message_handler should store handler."""
class ConcreteTransport(Transport):
async def start(self):
pass
async def stop(self):
pass
async def send(self, message: str):
pass
transport = ConcreteTransport()
handler = MagicMock()
transport.set_message_handler(handler)
assert transport._handler is handler
@pytest.mark.asyncio
async def test_start_not_implemented(self):
"""Transport.start should be abstract."""
# 抽象方法不能直接测试,跳过
pass
@pytest.mark.asyncio
async def test_stop_not_implemented(self):
"""Transport.stop should be abstract."""
# 抽象方法不能直接测试,跳过
pass
@pytest.mark.asyncio
async def test_send_not_implemented(self):
"""Transport.send should be abstract."""
# 抽象方法不能直接测试,跳过
pass
@pytest.mark.asyncio
async def test_wait_closed(self):
"""wait_closed should wait for _closed event."""
class ConcreteTransport(Transport):
async def start(self):
pass
async def stop(self):
pass
async def send(self, message: str):
pass
transport = ConcreteTransport()
transport._closed.set()
# Should return immediately since _closed is already set
await transport.wait_closed()
@pytest.mark.asyncio
async def test_dispatch_calls_handler(self):
"""_dispatch should call handler with payload."""
class ConcreteTransport(Transport):
async def start(self):
pass
async def stop(self):
pass
async def send(self, message: str):
pass
transport = ConcreteTransport()
handler = AsyncMock()
transport.set_message_handler(handler)
await transport._dispatch("test payload")
handler.assert_called_once_with("test payload")
@pytest.mark.asyncio
async def test_dispatch_without_handler(self):
"""_dispatch should work without handler."""
class ConcreteTransport(Transport):
async def start(self):
pass
async def stop(self):
pass
async def send(self, message: str):
pass
transport = ConcreteTransport()
# Should not raise when no handler is set
await transport._dispatch("test payload")
class TestStdioTransportInit:
"""Tests for StdioTransport initialization."""
def test_default_init(self):
"""StdioTransport should initialize with default values."""
transport = StdioTransport()
assert transport._stdin is None
assert transport._stdout is None
assert transport._command is None
assert transport._cwd is None
assert transport._env is None
assert transport._process is None
assert transport._reader_task is None
def test_with_custom_streams(self):
"""StdioTransport should accept custom stdin/stdout."""
stdin = StringIO()
stdout = StringIO()
transport = StdioTransport(stdin=stdin, stdout=stdout)
assert transport._stdin is stdin
assert transport._stdout is stdout
def test_with_command(self):
"""StdioTransport should accept command for subprocess."""
transport = StdioTransport(command=["python", "-m", "module"])
assert transport._command == ["python", "-m", "module"]
def test_with_cwd_and_env(self):
"""StdioTransport should accept cwd and env."""
transport = StdioTransport(cwd="/tmp", env={"VAR": "value"})
assert transport._cwd == "/tmp"
assert transport._env == {"VAR": "value"}
class TestStdioTransportFileMode:
"""Tests for StdioTransport in file mode (no subprocess)."""
@pytest.mark.asyncio
async def test_start_without_command(self):
"""start() without command should use stdin/stdout."""
transport = StdioTransport()
with patch("sys.stdin"), patch("sys.stdout"):
await transport.start()
assert transport._reader_task is not None
await transport.stop()
@pytest.mark.asyncio
async def test_stop_cancels_reader_task(self):
"""stop() should cancel reader task."""
transport = StdioTransport()
with patch("sys.stdin"), patch("sys.stdout"):
await transport.start()
task = transport._reader_task
await transport.stop()
assert task.cancelled() or task.done()
@pytest.mark.asyncio
async def test_send_without_process(self):
"""send() without process should write to stdout."""
stdout = MagicMock()
stdout.write = MagicMock()
stdout.flush = MagicMock()
transport = StdioTransport(stdout=stdout)
with patch("sys.stdin"):
await transport.start()
await transport.send("test message")
# Should have written the message with newline
stdout.write.assert_called_once_with("test message\n")
stdout.flush.assert_called_once()
await transport.stop()
@pytest.mark.asyncio
async def test_send_adds_newline_if_missing(self):
"""send() should add newline if not present."""
stdout = MagicMock()
stdout.write = MagicMock()
stdout.flush = MagicMock()
transport = StdioTransport(stdout=stdout)
with patch("sys.stdin"):
await transport.start()
await transport.send("message")
stdout.write.assert_called_once_with("message\n")
await transport.stop()
@pytest.mark.asyncio
async def test_send_preserves_existing_newline(self):
"""send() should not add extra newline."""
stdout = MagicMock()
stdout.write = MagicMock()
stdout.flush = MagicMock()
transport = StdioTransport(stdout=stdout)
with patch("sys.stdin"):
await transport.start()
await transport.send("message\n")
stdout.write.assert_called_once_with("message\n")
await transport.stop()
@pytest.mark.asyncio
async def test_send_raises_without_stdout(self):
"""send() should raise if stdout is None."""
transport = StdioTransport(stdout=None)
transport._stdout = None
with pytest.raises(RuntimeError, match="stdout"):
await transport.send("test")
class TestStdioTransportProcessMode:
"""Tests for StdioTransport in subprocess mode."""
@pytest.mark.asyncio
async def test_start_with_command_creates_process(self):
"""start() with command should create subprocess."""
# 使用 Python 解释器作为跨平台兼容的命令
import sys
transport = StdioTransport(command=[sys.executable, "-c", "print('test')"])
await transport.start()
assert transport._process is not None
assert transport._reader_task is not None
await transport.stop()
@pytest.mark.asyncio
async def test_stop_terminates_process(self):
"""stop() should terminate the subprocess."""
import sys
# 使用 Python 长时间运行的脚本替代 sleep
transport = StdioTransport(
command=[sys.executable, "-c", "import time; time.sleep(100)"]
)
await transport.start()
process = transport._process
assert process is not None
await transport.stop()
assert process.returncode is not None
@pytest.mark.asyncio
async def test_send_to_process(self):
"""send() should write to process stdin."""
import sys
# 使用 Python 脚本替代 cat读取 stdin 并输出
transport = StdioTransport(
command=[
sys.executable,
"-c",
"import sys; sys.stdout.write(sys.stdin.read())",
]
)
await transport.start()
# Should not raise
await transport.send("test data")
await transport.stop()
@pytest.mark.asyncio
async def test_send_raises_if_process_stdin_none(self):
"""send() should raise if process stdin is None."""
import sys
transport = StdioTransport(
command=[
sys.executable,
"-c",
"import sys; sys.stdout.write(sys.stdin.read())",
]
)
await transport.start()
# Manually set stdin to None to simulate error condition
if transport._process:
transport._process.stdin = None # type: ignore
with pytest.raises(RuntimeError, match="stdin"):
await transport.send("test")
await transport.stop()
class TestWebSocketServerTransportInit:
"""Tests for WebSocketServerTransport initialization."""
def test_default_init(self):
"""WebSocketServerTransport should have default values."""
transport = WebSocketServerTransport()
assert transport._host == "127.0.0.1"
assert transport._port == 8765
assert transport._path == "/"
assert transport._heartbeat == 30.0
assert transport._app is None
assert transport._ws is None
def test_custom_values(self):
"""WebSocketServerTransport should accept custom values."""
transport = WebSocketServerTransport(
host="0.0.0.0",
port=9000,
path="/ws",
heartbeat=60.0,
)
assert transport._host == "0.0.0.0"
assert transport._port == 9000
assert transport._path == "/ws"
assert transport._heartbeat == 60.0
def test_port_property_returns_actual_port(self):
"""port property should return actual port after start."""
transport = WebSocketServerTransport(port=8765)
# Before start, should return configured port
assert transport.port == 8765
def test_url_property(self):
"""url property should return WebSocket URL."""
transport = WebSocketServerTransport(host="localhost", port=8080, path="/ws")
assert transport.url == "ws://localhost:8080/ws"
class TestWebSocketServerTransportLifecycle:
"""Tests for WebSocketServerTransport lifecycle."""
@pytest.mark.asyncio
async def test_start_creates_app(self):
"""start() should create aiohttp app."""
transport = WebSocketServerTransport(port=0)
await transport.start()
assert transport._app is not None
assert transport._runner is not None
assert transport._site is not None
await transport.stop()
@pytest.mark.asyncio
async def test_stop_closes_websocket(self):
"""stop() should close the WebSocket."""
transport = WebSocketServerTransport(port=0)
await transport.start()
await transport.stop()
assert transport._ws is None
assert transport._runner is None
@pytest.mark.asyncio
async def test_send_waits_for_connection(self):
"""send() should wait for WebSocket connection."""
transport = WebSocketServerTransport(port=0)
await transport.start()
# Mock connected state
transport._connected.set()
# _ws 需要有异步的 send_str 方法
transport._ws = MagicMock()
transport._ws.closed = False
transport._ws.send_str = AsyncMock()
# close 也需要是异步的
transport._ws.close = AsyncMock()
await transport.send("test")
transport._ws.send_str.assert_called_once_with("test")
await transport.stop()
@pytest.mark.asyncio
async def test_send_raises_if_not_connected(self):
"""send() should raise if WebSocket not connected."""
transport = WebSocketServerTransport(port=0, heartbeat=0)
await transport.start()
# Set timeout to 0 for immediate failure
with pytest.raises((RuntimeError, asyncio.TimeoutError)):
await transport.send("test")
await transport.stop()
class TestWebSocketClientTransportInit:
"""Tests for WebSocketClientTransport initialization."""
def test_required_url(self):
"""WebSocketClientTransport requires url."""
transport = WebSocketClientTransport(url="ws://localhost:8080")
assert transport._url == "ws://localhost:8080"
assert transport._heartbeat == 30.0
def test_custom_heartbeat(self):
"""WebSocketClientTransport should accept custom heartbeat."""
transport = WebSocketClientTransport(url="ws://localhost:8080", heartbeat=60.0)
assert transport._heartbeat == 60.0
class TestWebSocketClientTransportLifecycle:
"""Tests for WebSocketClientTransport lifecycle."""
@pytest.mark.asyncio
async def test_start_creates_session(self):
"""start() should create aiohttp session and connect."""
server = WebSocketServerTransport(port=0)
await server.start()
client = WebSocketClientTransport(url=server.url)
await client.start()
assert client._session is not None
assert client._ws is not None
await client.stop()
await server.stop()
@pytest.mark.asyncio
async def test_stop_closes_session_and_websocket(self):
"""stop() should close session and WebSocket."""
server = WebSocketServerTransport(port=0)
await server.start()
client = WebSocketClientTransport(url=server.url)
await client.start()
await client.stop()
assert client._session is None
assert client._ws is None
await server.stop()
@pytest.mark.asyncio
async def test_send_after_start(self):
"""send() should work after start()."""
server = WebSocketServerTransport(port=0)
await server.start()
client = WebSocketClientTransport(url=server.url)
await client.start()
# Should not raise
await client.send("test message")
await client.stop()
await server.stop()
@pytest.mark.asyncio
async def test_send_raises_if_not_connected(self):
"""send() should raise if WebSocket not connected."""
client = WebSocketClientTransport(url="ws://localhost:99999")
with pytest.raises(RuntimeError, match="尚未连接"):
await client.send("test")
class TestTransportIntegration:
"""Integration tests for transport pairs."""
@pytest.mark.asyncio
async def test_websocket_client_server_communication(self):
"""WebSocket client and server should communicate."""
server = WebSocketServerTransport(port=0)
client = WebSocketClientTransport(url="ws://invalid")
received_messages = []
async def handle_message(payload: str):
received_messages.append(payload)
server.set_message_handler(handle_message)
await server.start()
# Create new client with correct URL
client = WebSocketClientTransport(url=server.url)
await client.start()
# Wait for connection
await asyncio.sleep(0.1)
await client.send("hello from client")
# Wait for message to be received
await asyncio.sleep(0.1)
assert "hello from client" in received_messages
await client.stop()
await server.stop()