"""group_horse_racing 运行时 API 改造测试。""" from __future__ import annotations import importlib.util import sys import types from datetime import datetime from pathlib import Path import aiohttp import aiosqlite import pytest PROJECT_ROOT = Path(__file__).resolve().parents[1] PLUGIN_DIR = PROJECT_ROOT / "danding_bot" / "plugins" / "group_horse_racing" def _load_module(full_name: str, path: Path): spec = importlib.util.spec_from_file_location(full_name, path) module = importlib.util.module_from_spec(spec) sys.modules[full_name] = module assert spec and spec.loader spec.loader.exec_module(module) return module def _install_nonebot_stubs() -> None: """安装 shared.py 导入所需的最小 NoneBot 类型桩。""" nonebot = sys.modules.setdefault("nonebot", types.ModuleType("nonebot")) adapters = sys.modules.setdefault("nonebot.adapters", types.ModuleType("nonebot.adapters")) onebot = sys.modules.setdefault("nonebot.adapters.onebot", types.ModuleType("nonebot.adapters.onebot")) v11 = types.ModuleType("nonebot.adapters.onebot.v11") class Bot: async def get_group_member_info(self, **kwargs): return {} class Event: def get_user_id(self): return str(getattr(self, "user_id", "")) class GroupMessageEvent(Event): pass class PrivateMessageEvent(Event): pass class Message(list): pass class MessageSegment: @staticmethod def image(data): return {"type": "image", "data": data} v11.Bot = Bot v11.Event = Event v11.GroupMessageEvent = GroupMessageEvent v11.PrivateMessageEvent = PrivateMessageEvent v11.Message = Message v11.MessageSegment = MessageSegment sys.modules["nonebot.adapters.onebot.v11"] = v11 nonebot.adapters = adapters adapters.onebot = onebot onebot.v11 = v11 def _install_qqpush_stubs() -> None: qqpush_config = types.ModuleType("danding_bot.plugins.danding_qqpush.config") qqpush_image = types.ModuleType("danding_bot.plugins.danding_qqpush.image_render") class QqPushConfig: FontPaths = [] class ImageRenderer: def __init__(self, **kwargs): self.kwargs = kwargs def render_to_base64(self, body, title=""): return f"base64://{title}:{body}" qqpush_config.Config = QqPushConfig qqpush_image.ImageRenderer = ImageRenderer sys.modules["danding_bot.plugins.danding_qqpush.config"] = qqpush_config sys.modules["danding_bot.plugins.danding_qqpush.image_render"] = qqpush_image def _install_points_stub() -> None: points_package = types.ModuleType("danding_bot.plugins.danding_points") class PointsApi: async def add_points(self, user_id, amount, source, reason=None): return True, 0 async def spend_points(self, user_id, amount, source, reason=None): return True, 0 async def set_points(self, user_id, amount, source, reason=None): return True, amount async def get_balance(self, user_id): return 0 points_package.points_api = PointsApi() sys.modules["danding_bot.plugins.danding_points"] = points_package def load_room_store_modules(): """直接加载 room_store 相关子模块,避免执行插件入口。""" package_name = "_horse_racing_room_store_under_test" package = types.ModuleType(package_name) package.__path__ = [str(PLUGIN_DIR)] sys.modules[package_name] = package config_module = _load_module(f"{package_name}.config", PLUGIN_DIR / "config.py") models_module = _load_module(f"{package_name}.models", PLUGIN_DIR / "models.py") room_store_module = _load_module(f"{package_name}.room_store", PLUGIN_DIR / "room_store.py") return config_module, models_module, room_store_module def load_shared_modules(): """加载 shared.py 及其依赖,同时隔离 NoneBot 和外部插件依赖。""" _install_nonebot_stubs() _install_qqpush_stubs() _install_points_stub() package_name = "_horse_racing_shared_under_test" package = types.ModuleType(package_name) package.__path__ = [str(PLUGIN_DIR)] sys.modules[package_name] = package config_module = _load_module(f"{package_name}.config", PLUGIN_DIR / "config.py") models_module = _load_module(f"{package_name}.models", PLUGIN_DIR / "models.py") package.plugin_config = config_module.Config(RACE_RENDER_AS_IMAGE=False, RACE_TICK_INTERVAL=0, RACE_DISTANCE=1) _load_module(f"{package_name}.room_store", PLUGIN_DIR / "room_store.py") _load_module(f"{package_name}.points_service", PLUGIN_DIR / "points_service.py") _load_module(f"{package_name}.race_engine", PLUGIN_DIR / "race_engine.py") _load_module(f"{package_name}.message_service", PLUGIN_DIR / "message_service.py") commands_package_name = f"{package_name}.commands" commands_package = types.ModuleType(commands_package_name) commands_package.__path__ = [str(PLUGIN_DIR / "commands")] sys.modules[commands_package_name] = commands_package access_module = types.ModuleType(f"{commands_package_name}.access") access_module.get_event_id = lambda event: str(getattr(event, "user_id", "")) access_module.get_scope = lambda event: f"group_{getattr(event, 'group_id', '')}" async def _check_access(bot, event): return True access_module.check_access = _check_access sys.modules[f"{commands_package_name}.access"] = access_module shared_module = _load_module(f"{commands_package_name}.shared", PLUGIN_DIR / "commands" / "shared.py") return config_module, models_module, shared_module config_module, models_module, room_store_module = load_room_store_modules() Config = config_module.Config RoomStore = room_store_module.RoomStore RaceResult = models_module.RaceResult class FakeResponse: """模拟 aiohttp 响应上下文。""" def __init__(self, payload, status=200): self.payload = payload self.status = status async def __aenter__(self): return self async def __aexit__(self, exc_type, exc, tb): return None async def json(self): return self.payload class FakeSession: """记录赛马 HTTP 请求参数的 aiohttp ClientSession 替身。""" def __init__(self, responses, calls): self.responses = responses self.calls = calls async def __aenter__(self): return self async def __aexit__(self, exc_type, exc, tb): return None def get(self, url, params=None, timeout=None): self.calls.append({"method": "GET", "url": url, "params": params, "timeout": timeout}) return FakeResponse(self.responses.pop(0)) def post(self, url, json=None, timeout=None): self.calls.append({"method": "POST", "url": url, "json": json, "timeout": timeout}) return FakeResponse(self.responses.pop(0)) def put(self, url, json=None, timeout=None): self.calls.append({"method": "PUT", "url": url, "json": json, "timeout": timeout}) return FakeResponse(self.responses.pop(0)) def success_data(data): return {"code": 200, "message": "", "data": data} @pytest.fixture def fake_aiohttp(monkeypatch): calls = [] responses = [] monkeypatch.setattr(room_store_module.aiohttp, "ClientSession", lambda: FakeSession(responses, calls)) return responses, calls def make_store(tmp_path: Path) -> RoomStore: return RoomStore( Config( RACE_DB_FILE=str(tmp_path / "race.db"), RACE_API_HOST="http://xapi.test/bot/race/", BOT_USER="robot", BOT_TOKEN="secret", ) ) @pytest.mark.asyncio async def test_room_store_race_history_and_horse_names_use_xapi(fake_aiohttp, tmp_path): responses, calls = fake_aiohttp responses.extend( [ success_data({"user_id": "10001", "horse_name": "赤焰"}), success_data({"success": True}), success_data({"race_id": "race-001"}), ] ) store = make_store(tmp_path) result = RaceResult( race_id="race-001", scope="group_1000", champion_name="赤焰", champion_owner="10001", participants=["赤焰", "青岚"], bet_distribution={"赤焰": 30, "青岚": 10}, duration_ticks=8, completed_at=datetime(2026, 6, 20, 10, 0, 0), point_changes={"10001": 170}, point_change_summaries={"10001": "大赚特赚"}, odds_snapshot={"赤焰": 1.5}, ) horse_name = await store.get_last_horse_name("10001") await store.set_last_horse_name("10001", "青岚") await store.save_race_result(result) assert horse_name == "赤焰" assert calls[0]["method"] == "GET" assert calls[0]["url"] == "http://xapi.test/bot/race/horse-name" assert calls[0]["params"] == {"user": "robot", "token": "secret", "user_id": "10001"} assert calls[1]["method"] == "PUT" assert calls[1]["json"] == {"user": "robot", "token": "secret", "user_id": "10001", "horse_name": "青岚"} assert calls[2]["method"] == "POST" assert calls[2]["url"] == "http://xapi.test/bot/race/history" assert calls[2]["json"]["race_id"] == "race-001" assert calls[2]["json"]["participants"] == ["赤焰", "青岚"] assert calls[2]["json"]["odds_snapshot"] == {"赤焰": 1.5} @pytest.mark.asyncio async def test_room_snapshots_still_use_local_sqlite_without_race_http(fake_aiohttp, tmp_path): _responses, calls = fake_aiohttp store = make_store(tmp_path) room = await store.create_room("group_1000") loaded = store.get_room("group_1000") assert loaded is room assert calls == [] async with aiosqlite.connect(store.db_path) as db: cursor = await db.execute("SELECT scope, state FROM room_snapshots") rows = await cursor.fetchall() await store.close() assert rows == [("group_1000", "waiting")] @pytest.mark.asyncio async def test_race_api_network_error_keeps_old_failure_shapes(monkeypatch, tmp_path): class FailingSession: async def __aenter__(self): raise aiohttp.ClientError("offline") async def __aexit__(self, exc_type, exc, tb): return None monkeypatch.setattr(room_store_module.aiohttp, "ClientSession", lambda: FailingSession()) store = make_store(tmp_path) assert await store.get_last_horse_name("10001") is None await store.set_last_horse_name("10001", "赤焰") @pytest.mark.asyncio async def test_save_race_result_raises_when_xapi_rejects(fake_aiohttp, tmp_path): responses, _calls = fake_aiohttp responses.append({"code": 500, "message": "写入失败", "data": None}) store = make_store(tmp_path) result = RaceResult( race_id="race-rejected", scope="group_1000", champion_name="赤焰", champion_owner="10001", participants=["赤焰"], bet_distribution={"赤焰": 0}, duration_ticks=1, completed_at=datetime(2026, 6, 20, 10, 0, 0), point_changes={"10001": 170}, point_change_summaries={"10001": "大赚特赚"}, odds_snapshot={"赤焰": 1.2}, ) with pytest.raises(RuntimeError, match="赛马赛果写入 xapi 失败"): await store.save_race_result(result) @pytest.mark.asyncio async def test_settle_race_awaits_balances_and_builds_complete_result(): _config_module, shared_models, shared = load_shared_modules() Room = shared_models.Room Horse = shared_models.Horse HorseState = shared_models.HorseState Bet = shared_models.Bet class FakePointsService: def __init__(self): self.balances = {"10001": 100, "10002": 50, "20001": 200} async def get_balance(self, user_id: str) -> int: return self.balances[user_id] async def reward_participant(self, user_id: str): self.balances[user_id] += shared.config.PARTICIPANT_REWARD return True, self.balances[user_id] async def reward_champion(self, user_id: str): self.balances[user_id] += shared.config.CHAMPION_REWARD return True, self.balances[user_id] async def payout_winnings(self, user_id: str, amount: int, odds: float): self.balances[user_id] += max(1, round(amount * odds)) return True, self.balances[user_id] shared.points_service = FakePointsService() room = Room(scope="group_1000") room.horses = { "赤焰": Horse(owner_id="10001", name="赤焰", index=1, state=HorseState.RACING), "青岚": Horse(owner_id="10002", name="青岚", index=2, state=HorseState.RACING), } room.bets = [Bet(user_id="20001", horse_name="赤焰", amount=30)] room.champion_name = "赤焰" room.tick_count = 7 settlement = await shared.settle_race(room) assert settlement is not None result, odds = settlement assert result.race_id assert result.scope == "group_1000" assert result.participants == ["赤焰", "青岚"] assert result.bet_distribution == {"赤焰": 30, "青岚": 0} assert result.duration_ticks == 7 assert result.odds_snapshot == odds assert result.point_changes == {"10001": 170, "10002": 20, "20001": 36} assert all(isinstance(value, int) for value in result.point_changes.values()) @pytest.mark.asyncio async def test_run_race_with_settlement_saves_result_before_deleting_room(): _config_module, shared_models, shared = load_shared_modules() Room = shared_models.Room Horse = shared_models.Horse HorseState = shared_models.HorseState Bet = shared_models.Bet class FakeRaceEngine: def __init__(self): self.stopped = [] def tick(self, room): room.tick_count = 3 return [room.horses["赤焰"]] def format_progress(self, room): return "progress" def determine_champion(self, horses): return horses[0] def stop_race(self, scope): self.stopped.append(scope) class FakePointsService: def __init__(self): self.balances = {"10001": 100, "10002": 50, "20001": 200} async def get_balance(self, user_id: str) -> int: return self.balances[user_id] async def reward_participant(self, user_id: str): self.balances[user_id] += shared.config.PARTICIPANT_REWARD return True, self.balances[user_id] async def reward_champion(self, user_id: str): self.balances[user_id] += shared.config.CHAMPION_REWARD return True, self.balances[user_id] async def payout_winnings(self, user_id: str, amount: int, odds: float): self.balances[user_id] += max(1, round(amount * odds)) return True, self.balances[user_id] class FakeMessageService: def __init__(self): self.sent = [] self.cleared = [] async def send_with_recall(self, bot, scope, message_type, message): self.sent.append((scope, message_type, str(message))) return "msg" async def recall_previous_of_type(self, bot, scope, message_type): return None def clear_pending_recalls(self, scope): self.cleared.append(scope) class FakeRoomStore: def __init__(self): self.saved = [] self.deleted = [] async def save_race_result(self, result): self.saved.append(result) def delete_room(self, scope): self.deleted.append(scope) shared.race_engine = FakeRaceEngine() shared.points_service = FakePointsService() shared.message_service = FakeMessageService() shared.room_store = FakeRoomStore() shared.config.RACE_TICK_INTERVAL = 0 room = Room(scope="group_1000") room.horses = { "赤焰": Horse(owner_id="10001", name="赤焰", index=1, state=HorseState.RACING), "青岚": Horse(owner_id="10002", name="青岚", index=2, state=HorseState.RACING), } room.bets = [Bet(user_id="20001", horse_name="赤焰", amount=30)] await shared.run_race_with_settlement(object(), room, "group_1000") assert len(shared.room_store.saved) == 1 saved = shared.room_store.saved[0] assert saved.scope == "group_1000" assert saved.duration_ticks == 3 assert saved.odds_snapshot == {"赤焰": 1.2, "青岚": 1.2} assert shared.room_store.deleted == ["group_1000"] assert shared.race_engine.stopped == ["group_1000"] assert shared.message_service.cleared == ["group_1000"]