Files
AstrBot/tests/unit/test_platform.py
LIghtJUNction 169a856a3a test: mass-coverage push — ~1000+ new unit tests
Coverage improvements:
- agent/tool/core: 150+ tests (FunctionTool, ToolSet, ToolSchema, run_context, messages, registry)
- provider: 152 tests (entities, Provider ABC, register, manager)
- star module: 58 tests (Context, PluginManager, StarHandler)
- platform: 71 tests (Platform ABC, manager, MessageSession)
- pipeline: 130 tests (process/respond stages, scheduler, bootstrap, conversation_mgr)
- kb+skills: 96 tests (mgr, helpers, chunkers, skill_manager)
- config+builtins: 92 tests (astrbot_config, config_mgr, default, LTM, star main)
- cron+db+event_bus: 89 tests (cron events/manager, PO models, BaseDatabase, EventBus)
- dashboard routes: expanded smoke tests for all route modules
2026-04-29 08:40:26 +08:00

313 lines
11 KiB
Python

"""Tests for astrbot.core.platform.platform — Platform ABC."""
import asyncio
from asyncio import Queue
from datetime import datetime
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from astrbot.core.platform.platform import Platform, PlatformError, PlatformStatus
from astrbot.core.platform.platform_metadata import PlatformMetadata
# ---------------------------------------------------------------------------
# Concrete subclass so we can test non-abstract behaviour of Platform.
# ---------------------------------------------------------------------------
class ConcretePlatform(Platform):
"""Minimal concrete Platform used in every test that needs an instance."""
async def run(self) -> None:
pass
def meta(self) -> PlatformMetadata:
return PlatformMetadata(
name="test_adapter",
description="A test adapter",
id="test_adapter_id",
adapter_display_name="Test Adapter",
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def config() -> dict:
return {
"key": "value",
"unified_webhook_mode": False,
}
@pytest.fixture
def event_queue() -> Queue:
return Queue()
@pytest.fixture
def platform(config: dict, event_queue: Queue) -> ConcretePlatform:
return ConcretePlatform(config, event_queue)
# ===================================================================
# Construction
# ===================================================================
class TestConstruction:
"""Platform.__init__ stores constructor arguments and sets defaults."""
def test_stores_config_and_queue(self, config: dict, event_queue: Queue):
p = ConcretePlatform(config, event_queue)
assert p.config is config
assert p._event_queue is event_queue
def test_default_status_is_pending(self, platform: ConcretePlatform):
assert platform.status == PlatformStatus.PENDING
def test_client_self_id_is_random_hex(self, platform: ConcretePlatform):
assert isinstance(platform.client_self_id, str)
assert len(platform.client_self_id) == 32 # uuid4 hex
def test_errors_list_starts_empty(self, platform: ConcretePlatform):
assert platform.errors == []
def test_started_at_is_none_until_running(self, platform: ConcretePlatform):
assert platform._started_at is None
# ===================================================================
# Status property
# ===================================================================
class TestStatus:
"""Platform.status getter/setter and side-effects."""
def test_setter_changes_status(self, platform: ConcretePlatform):
platform.status = PlatformStatus.RUNNING
assert platform.status == PlatformStatus.RUNNING
def test_setting_running_records_started_at(self, platform: ConcretePlatform):
platform.status = PlatformStatus.RUNNING
assert platform._started_at is not None
assert isinstance(platform._started_at, datetime)
def test_setting_running_twice_does_not_overwrite_started_at(
self, platform: ConcretePlatform
):
platform.status = PlatformStatus.RUNNING
t1 = platform._started_at
platform.status = PlatformStatus.ERROR
platform.status = PlatformStatus.RUNNING
assert platform._started_at == t1
# ===================================================================
# Error paths
# ===================================================================
class TestErrors:
"""record_error, last_error, clear_errors."""
def test_record_error_appends_to_list(self, platform: ConcretePlatform):
platform.record_error("something went wrong", "traceback line 1")
assert len(platform.errors) == 1
assert platform.errors[0].message == "something went wrong"
assert platform.errors[0].traceback == "traceback line 1"
def test_record_error_sets_status_to_error(self, platform: ConcretePlatform):
platform.record_error("fail")
assert platform.status == PlatformStatus.ERROR
def test_last_error_none_when_empty(self, platform: ConcretePlatform):
assert platform.last_error is None
def test_last_error_returns_most_recent(self, platform: ConcretePlatform):
platform.record_error("first")
platform.record_error("second")
assert platform.last_error.message == "second"
def test_clear_errors_empties_list(self, platform: ConcretePlatform):
platform.record_error("first")
platform.clear_errors()
assert platform.errors == []
def test_clear_errors_resets_status_from_error(self, platform: ConcretePlatform):
platform.record_error("first")
platform.clear_errors()
assert platform.status == PlatformStatus.RUNNING
def test_clear_errors_is_noop_when_no_error(self, platform: ConcretePlatform):
platform.clear_errors()
assert platform.errors == []
assert platform.status == PlatformStatus.PENDING
# ===================================================================
# unified_webhook
# ===================================================================
class TestUnifiedWebhook:
"""Platform.unified_webhook() logic."""
def test_disabled_by_default(self, platform: ConcretePlatform):
assert platform.unified_webhook() is False
def test_enabled_when_both_config_present(
self, config: dict, event_queue: Queue
):
config["unified_webhook_mode"] = True
config["webhook_uuid"] = "abc123"
p = ConcretePlatform(config, event_queue)
assert p.unified_webhook() is True
def test_disabled_without_uuid(self, config: dict, event_queue: Queue):
config["unified_webhook_mode"] = True
# no webhook_uuid set
p = ConcretePlatform(config, event_queue)
assert p.unified_webhook() is False
def test_disabled_when_mode_off(self, config: dict, event_queue: Queue):
config["unified_webhook_mode"] = False
config["webhook_uuid"] = "abc123"
p = ConcretePlatform(config, event_queue)
assert p.unified_webhook() is False
# ===================================================================
# get_stats
# ===================================================================
class TestGetStats:
"""Platform.get_stats() structure and content."""
def test_stats_contains_expected_keys(self, platform: ConcretePlatform):
stats = platform.get_stats()
expected_keys = {
"id", "type", "display_name", "status", "started_at",
"error_count", "last_error", "unified_webhook", "meta",
}
assert expected_keys.issubset(stats.keys())
def test_stats_values_without_errors(self, platform: ConcretePlatform):
stats = platform.get_stats()
assert stats["id"] == "test_adapter_id"
assert stats["type"] == "test_adapter"
assert stats["display_name"] == "Test Adapter"
assert stats["status"] == PlatformStatus.PENDING.value
assert stats["started_at"] is None
assert stats["error_count"] == 0
assert stats["last_error"] is None
assert stats["unified_webhook"] is False
assert stats["meta"]["id"] == "test_adapter_id"
assert stats["meta"]["name"] == "test_adapter"
def test_stats_reflects_recorded_errors(self, platform: ConcretePlatform):
platform.record_error("err1", "tb1")
platform.record_error("err2", "tb2")
stats = platform.get_stats()
assert stats["error_count"] == 2
assert stats["last_error"]["message"] == "err2"
assert stats["last_error"]["traceback"] == "tb2"
# ===================================================================
# Instance methods
# ===================================================================
class TestMethods:
"""terminate, get_client, commit_event, webhook_callback, send_by_session."""
@pytest.mark.asyncio
async def test_terminate_sets_stopped(self, platform: ConcretePlatform):
await platform.terminate()
assert platform.status == PlatformStatus.STOPPED
def test_get_client_returns_none(self, platform: ConcretePlatform):
assert platform.get_client() is None
@pytest.mark.asyncio
async def test_commit_event_puts_into_queue(
self, platform: ConcretePlatform, event_queue: Queue
):
mock_event = MagicMock()
platform.commit_event(mock_event)
assert event_queue.qsize() == 1
assert await event_queue.get() is mock_event
@pytest.mark.asyncio
async def test_webhook_callback_raises_not_implemented(
self, platform: ConcretePlatform
):
with pytest.raises(NotImplementedError) as exc:
await platform.webhook_callback(None)
assert "未实现统一 Webhook 模式" in str(exc.value)
@pytest.mark.asyncio
async def test_send_by_session_calls_metric_upload(
self, platform: ConcretePlatform
):
mock_session = MagicMock()
mock_chain = MagicMock()
with patch(
"astrbot.core.platform.platform.Metric.upload",
new_callable=AsyncMock,
) as mock_upload:
await platform.send_by_session(mock_session, mock_chain)
mock_upload.assert_called_once_with(
msg_event_tick=1, adapter_name="test_adapter"
)
# ===================================================================
# Abstract-method detection (cannot instantiate ABC directly)
# ===================================================================
class TestAbstractDetection:
"""Verify the ABC enforces that run() and meta() are implemented."""
def test_cannot_instantiate_platform_directly(self):
with pytest.raises(TypeError):
Platform({"k": "v"}, Queue()) # type: ignore[abstract]
def test_missing_run_raises_type_error(self):
class MissingRun(Platform):
def meta(self) -> PlatformMetadata:
return PlatformMetadata(name="x", description="x", id="x")
with pytest.raises(TypeError):
MissingRun({}, Queue()) # type: ignore[abstract]
def test_missing_meta_raises_type_error(self):
class MissingMeta(Platform):
async def run(self) -> None:
pass
with pytest.raises(TypeError):
MissingMeta({}, Queue()) # type: ignore[abstract]
# ===================================================================
# PlatformError dataclass
# ===================================================================
class TestPlatformErrorDataclass:
"""PlatformError construction and defaults."""
def test_required_message(self):
err = PlatformError(message="oops")
assert err.message == "oops"
assert err.traceback is None
assert isinstance(err.timestamp, datetime)
def test_with_traceback(self):
err = PlatformError(message="oops", traceback="tb content")
assert err.traceback == "tb content"
def test_default_timestamp_is_nowish(self):
before = datetime.now()
err = PlatformError(message="oops")
after = datetime.now()
assert before <= err.timestamp <= after