Files
AstrBot/tests/unit/test_manager.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

336 lines
12 KiB
Python

"""Tests for astrbot.core.platform.manager — PlatformManager."""
import asyncio
from asyncio import Queue
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from astrbot.core.platform.manager import PlatformManager
from astrbot.core.platform.platform import Platform, PlatformError, PlatformStatus
# ---------------------------------------------------------------------------
# A minimal concrete Platform we can wire into the manager without real I/O.
# ---------------------------------------------------------------------------
class DummyPlatform(Platform):
"""Lightweight Platform used inside manager tests."""
def __init__(
self,
config: dict,
settings: dict,
event_queue: Queue,
**kwargs,
) -> None:
super().__init__(config, event_queue)
self.settings = settings
self._terminated = False
async def run(self) -> None:
pass
def meta(self):
from astrbot.core.platform.platform_metadata import PlatformMetadata
return PlatformMetadata(
name=self.config.get("type", "dummy"),
description="dummy",
id=self.config.get("id", "dummy_id"),
)
async def terminate(self) -> None:
self._terminated = True
await super().terminate()
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def mock_config() -> MagicMock:
"""A dict-like MagicMock that behaves like AstrBotConfig for key lookups."""
cfg = MagicMock()
cfg.__getitem__.side_effect = lambda k: {
"platform": [],
"platform_settings": {"unique_session": True},
}[k]
return cfg
@pytest.fixture
def event_queue() -> Queue:
return Queue()
@pytest.fixture
def manager(mock_config: MagicMock, event_queue: Queue) -> PlatformManager:
return PlatformManager(mock_config, event_queue)
# ===================================================================
# Construction
# ===================================================================
class TestConstruction:
"""PlatformManager.__init__ stores constructor arguments and initialises
empty collections."""
def test_stores_config(self, mock_config: MagicMock, event_queue: Queue):
m = PlatformManager(mock_config, event_queue)
assert m.astrbot_config is mock_config
assert m.event_queue is event_queue
def test_platforms_config_and_settings_are_extracted(
self, mock_config: MagicMock, event_queue: Queue
):
m = PlatformManager(mock_config, event_queue)
assert m.platforms_config == []
assert m.settings == {"unique_session": True}
def test_initial_collections_are_empty(
self, mock_config: MagicMock, event_queue: Queue
):
m = PlatformManager(mock_config, event_queue)
assert m.platform_insts == []
assert m._inst_map == {}
assert m._platform_tasks == {}
# ===================================================================
# _is_valid_platform_id
# ===================================================================
class TestIsValidPlatformId:
"""_is_valid_platform_id rejects None, empty, or ids containing ':'/'!'."""
def test_valid_id(self, manager: PlatformManager):
assert manager._is_valid_platform_id("my_platform") is True
assert manager._is_valid_platform_id("platform123") is True
def test_none_is_invalid(self, manager: PlatformManager):
assert manager._is_valid_platform_id(None) is False
def test_empty_string_is_invalid(self, manager: PlatformManager):
assert manager._is_valid_platform_id("") is False
def test_colon_is_invalid(self, manager: PlatformManager):
assert manager._is_valid_platform_id("plat:form") is False
def test_exclamation_is_invalid(self, manager: PlatformManager):
assert manager._is_valid_platform_id("plat!form") is False
# ===================================================================
# _sanitize_platform_id
# ===================================================================
class TestSanitizePlatformId:
"""_sanitize_platform_id replaces ':'/'!' with '_'."""
def test_clean_id_unchanged(self, manager: PlatformManager):
result, changed = manager._sanitize_platform_id("my_platform")
assert result == "my_platform"
assert changed is False
def test_colon_replaced(self, manager: PlatformManager):
result, changed = manager._sanitize_platform_id("my:platform")
assert result == "my_platform"
assert changed is True
def test_exclamation_replaced(self, manager: PlatformManager):
result, changed = manager._sanitize_platform_id("my!platform")
assert result == "my_platform"
assert changed is True
def test_both_replaced(self, manager: PlatformManager):
result, changed = manager._sanitize_platform_id("a:b!c")
assert result == "a_b_c"
assert changed is True
def test_none_returns_none_no_change(self, manager: PlatformManager):
result, changed = manager._sanitize_platform_id(None)
assert result is None
assert changed is False
# ===================================================================
# get_insts
# ===================================================================
class TestGetInsts:
"""get_insts returns the internal platform_insts list."""
def test_returns_same_list_object(self, manager: PlatformManager):
assert manager.get_insts() is manager.platform_insts
def test_empty_by_default(self, manager: PlatformManager):
assert manager.get_insts() == []
# ===================================================================
# get_all_stats
# ===================================================================
class TestGetAllStats:
"""get_all_stats aggregates stats from all registered platforms."""
def test_empty_when_no_platforms(self, manager: PlatformManager):
stats = manager.get_all_stats()
assert stats["platforms"] == []
assert stats["summary"]["total"] == 0
assert stats["summary"]["running"] == 0
assert stats["summary"]["error"] == 0
assert stats["summary"]["total_errors"] == 0
def test_aggregates_mixed_statuses(self, manager: PlatformManager):
run_mock = MagicMock(spec=Platform)
run_mock.get_stats.return_value = {
"id": "p1",
"type": "mock",
"display_name": "Mock1",
"status": PlatformStatus.RUNNING.value,
"started_at": None,
"error_count": 0,
"last_error": None,
"unified_webhook": False,
"meta": {},
}
err_mock = MagicMock(spec=Platform)
err_mock.get_stats.return_value = {
"id": "p2",
"type": "mock",
"display_name": "Mock2",
"status": PlatformStatus.ERROR.value,
"started_at": None,
"error_count": 2,
"last_error": {"message": "fail"},
"unified_webhook": False,
"meta": {},
}
manager.platform_insts = [run_mock, err_mock]
stats = manager.get_all_stats()
assert stats["summary"]["total"] == 2
assert stats["summary"]["running"] == 1
assert stats["summary"]["error"] == 1
assert stats["summary"]["total_errors"] == 2
def test_recovers_from_broken_platform(self, manager: PlatformManager):
bad = MagicMock(spec=Platform)
bad.get_stats.side_effect = Exception("oops")
bad.config = {"id": "broken"}
manager.platform_insts = [bad]
stats = manager.get_all_stats()
assert stats["summary"]["total"] == 1
platform_info = stats["platforms"][0]
assert platform_info["id"] == "broken"
assert platform_info["status"] == "unknown"
assert platform_info["type"] == "unknown"
# ===================================================================
# terminate_platform
# ===================================================================
class TestTerminatePlatform:
"""terminate_platform removes the platform from maps and calls terminate."""
@pytest.mark.asyncio
async def test_removes_and_terminates(self, manager: PlatformManager):
p = DummyPlatform(
{"id": "test_id", "type": "dummy"}, {}, Queue()
)
manager._inst_map["test_id"] = {
"inst": p,
"client_id": p.client_self_id,
}
manager.platform_insts.append(p)
# Stop the underlying task machinery from actually creating tasks.
with patch.object(manager, "_stop_platform_task", new_callable=AsyncMock):
await manager.terminate_platform("test_id")
assert p._terminated is True
assert "test_id" not in manager._inst_map
assert p not in manager.platform_insts
# ===================================================================
# terminate (all)
# ===================================================================
class TestTerminateAll:
"""terminate() cleans up all registered platforms."""
@pytest.mark.asyncio
async def test_terminates_all_platforms(self, manager: PlatformManager):
p1 = DummyPlatform({"id": "p1"}, {}, Queue())
p2 = DummyPlatform({"id": "p2"}, {}, Queue())
manager._inst_map["p1"] = {"inst": p1, "client_id": p1.client_self_id}
manager._inst_map["p2"] = {"inst": p2, "client_id": p2.client_self_id}
manager.platform_insts = [p1, p2]
with patch.object(manager, "_stop_platform_task", new_callable=AsyncMock):
await manager.terminate()
assert p1._terminated is True
assert p2._terminated is True
assert manager.platform_insts == []
assert manager._inst_map == {}
assert manager._platform_tasks == {}
# ===================================================================
# load_platform
# ===================================================================
class TestLoadPlatform:
"""load_platform handles disabled flag and invalid IDs."""
@pytest.mark.asyncio
async def test_skips_disabled_platform(self, manager: PlatformManager):
"""When enable is False, load_platform should return immediately."""
with patch("astrbot.core.platform.manager.logger") as mock_logger:
await manager.load_platform({"enable": False})
# info should not have been called with the loading message
for call in mock_logger.info.call_args_list:
assert "Loading" not in str(call)
@pytest.mark.asyncio
async def test_sanitizes_invalid_platform_id(
self, manager: PlatformManager
):
"""A platform ID containing ':' should be sanitized automatically."""
platform_cfg = {
"enable": True,
"type": "nonexistent_type",
"id": "bad:id",
}
with (
patch.object(manager.astrbot_config, "save_config"),
patch("astrbot.core.platform.manager.logger"),
):
await manager.load_platform(platform_cfg)
assert platform_cfg["id"] == "bad_id"
@pytest.mark.asyncio
async def test_logs_error_when_type_not_in_map(
self, manager: PlatformManager
):
"""If the platform type is not registered, load_platform logs an error."""
platform_cfg = {
"enable": True,
"type": "no_such_adapter",
"id": "test_id",
}
with patch("astrbot.core.platform.manager.logger") as mock_logger:
await manager.load_platform(platform_cfg)
# Should have logged an error about adapter not found
error_messages = [
str(c) for c in mock_logger.error.call_args_list
]
assert any("not found" in msg for msg in error_messages)