"""MC 服务器数据层 (services/servers.py) 单元测试 servers.py 顶层不 import nonebot(运行态 config 是运行时才取),但 config.py 里有 `from .services import ...` 这类相对导入,所以这里**按包结构**用 importlib 加载(而不是像 test_video_policy.py 那样裸加载单文件):造一棵一次性的包骨架 塞进 sys.modules,真正的插件包 `nonebot_plugin_mc_server_status` 全程没被 NoneBot 加载过。 **每个用例重建一份 servers/config 模块**:增删改落在 `config.var.group_list` 上,直接复用进程里已有的 config 会把正在跑的 bot 的内存态改写掉(测试污染运行 数据),所以每次都用全新的模块对象 + tmp_path 下的数据文件。 """ import base64 import importlib.util import json import sys import types from pathlib import Path import pytest _PLUGIN_DIR = ( Path(__file__).resolve().parents[1] / "hexi" / "plugins" / "nonebot_plugin_mc_server_status" ) pytestmark = pytest.mark.skipif( not (_PLUGIN_DIR / "config.py").exists(), reason="MC 插件不存在" ) _PKG = "mc_status_testpkg" _SERVERS = f"{_PKG}.services.servers" _CONFIG = f"{_PKG}.config" def _load_by_path(name: str, path: Path): spec = importlib.util.spec_from_file_location( name, path, submodule_search_locations=[] if path.is_dir() else None ) module = importlib.util.module_from_spec(spec) sys.modules[name] = module spec.loader.exec_module(module) return module class _StubDriver: """config.py 的 @driver.on_startup / on_bot_connect 只登记回调,测试里不触发。""" def __getattr__(self, _name): def _decorator(*_args, **_kwargs): return lambda func: func return _decorator def _init_nonebot_stubs(): """config.py 顶层要 get_driver()/get_plugin_config()/nonebot.log,没有运行时就得打桩。 先真 `import nonebot` 再把这几个入口换掉(而不是塞一个假包进 sys.modules): 假包会让 python 用它的 `__path__` 去找 `nonebot.adapters`,反而找不到真子模块。 """ import nonebot pkg = types.ModuleType("nonebot.log") pkg.logger = types.SimpleNamespace( # type: ignore[attr-defined] debug=lambda *a, **k: None, info=lambda *a, **k: None, warning=lambda *a, **k: None, error=lambda *a, **k: None, ) saved = { "get_driver": nonebot.get_driver, # type: ignore[attr-defined] "get_plugin_config": nonebot.get_plugin_config, # type: ignore[attr-defined] "get_bot": nonebot.get_bot, # type: ignore[attr-defined] "get_bots": nonebot.get_bots, # type: ignore[attr-defined] "log": sys.modules.get("nonebot.log"), } nonebot.get_driver = lambda: _StubDriver() # type: ignore[attr-defined] nonebot.get_plugin_config = lambda *_a, **_k: types.SimpleNamespace( # type: ignore[attr-defined] mc_status_data_filename="mc_status_data.json" ) nonebot.get_bot = lambda *_a, **_k: None # type: ignore[attr-defined] nonebot.get_bots = lambda: {} # type: ignore[attr-defined] sys.modules["nonebot.log"] = pkg def restore() -> None: nonebot.get_driver = saved["get_driver"] # type: ignore[attr-defined] nonebot.get_plugin_config = saved["get_plugin_config"] # type: ignore[attr-defined] nonebot.get_bot = saved["get_bot"] # type: ignore[attr-defined] nonebot.get_bots = saved["get_bots"] # type: ignore[attr-defined] if saved["log"] is None: sys.modules.pop("nonebot.log", None) else: sys.modules["nonebot.log"] = saved["log"] return restore @pytest.fixture def mcs(tmp_path): """一套干净的数据层:一次性包骨架 + 全新 servers/config 模块 + 临时数据文件。""" pkg = types.ModuleType(_PKG) pkg.__path__ = [str(_PLUGIN_DIR)] # type: ignore[attr-defined] sys.modules[_PKG] = pkg services = types.ModuleType(f"{_PKG}.services") services.__path__ = [str(_PLUGIN_DIR / "services")] # type: ignore[attr-defined] sys.modules[f"{_PKG}.services"] = services restore = _init_nonebot_stubs() try: module = _load_by_path(_SERVERS, _PLUGIN_DIR / "services" / "servers.py") runtime = _load_by_path(_CONFIG, _PLUGIN_DIR / "config.py") finally: restore() # config.py 的 on_startup 不在测试里跑,内存态与数据文件名手动准备 runtime.var.group_list.clear() runtime.pc.mc_status_data_filename = str(tmp_path / "mc_status_data.json") try: yield module finally: for name in (_SERVERS, _CONFIG, f"{_PKG}.services", _PKG): sys.modules.pop(name, None) @pytest.fixture def groups(mcs): return mcs._runtime().var.group_list @pytest.fixture def mc_module(): """加载 services/mc.py(只测纯函数 _server_icon,不联网)。 mc.py 在模块顶层 import 了 OneBot 的消息类型,这里给个占位桩 —— 测试只关心 图标解码,不会真的构造 Message。 """ pkg = types.ModuleType(_PKG) pkg.__path__ = [str(_PLUGIN_DIR)] # type: ignore[attr-defined] sys.modules[_PKG] = pkg services = types.ModuleType(f"{_PKG}.services") services.__path__ = [str(_PLUGIN_DIR / "services")] # type: ignore[attr-defined] sys.modules[f"{_PKG}.services"] = services adapter = types.ModuleType("nonebot.adapters.onebot.v11") adapter.Message = type("Message", (), {}) adapter.MessageSegment = types.SimpleNamespace(image=lambda *_a, **_k: object()) saved = sys.modules.get("nonebot.adapters.onebot.v11") sys.modules["nonebot.adapters.onebot.v11"] = adapter try: module = _load_by_path( f"{_PKG}.services.mc", _PLUGIN_DIR / "services" / "mc.py" ) finally: if saved is None: sys.modules.pop("nonebot.adapters.onebot.v11", None) else: sys.modules["nonebot.adapters.onebot.v11"] = saved try: yield module finally: for name in (f"{_PKG}.services.mc", f"{_PKG}.services", _PKG): sys.modules.pop(name, None) # ───────────────────────── 服务器图标(mcstatus 跨版本) ───────────────────────── _PNG = b"\x89PNG\r\n\x1a\n" + b"fake-image-bytes" def test_server_icon_accepts_new_and_old_attribute_names(mc_module): """mcstatus ≥13 是 icon,11/12 是 favicon —— 两种都要认(线上 13.0.1 踩过一次)。""" b64 = base64.b64encode(_PNG).decode() new_status = types.SimpleNamespace(icon=f"data:image/png;base64,{b64}") old_status = types.SimpleNamespace(favicon=f"data:image/png;base64,{b64}") assert mc_module._server_icon(new_status) == _PNG assert mc_module._server_icon(old_status) == _PNG def test_server_icon_handles_bare_base64_and_missing(mc_module): b64 = base64.b64encode(_PNG).decode() assert mc_module._server_icon(types.SimpleNamespace(icon=b64)) == _PNG # 无 data URI 前缀 assert mc_module._server_icon(types.SimpleNamespace(icon=None)) is None assert mc_module._server_icon(types.SimpleNamespace()) is None # 两个属性都没有 assert mc_module._server_icon(types.SimpleNamespace(icon="不是 base64!!")) is None def _file(mcs) -> dict: return json.loads(Path(mcs._runtime().pc.mc_status_data_filename).read_text(encoding="utf-8")) # ───────────────────────── 校验 / 归一化 ───────────────────────── def test_normalize_type_aliases_and_errors(mcs): assert mcs.normalize_type("js") == "js" assert mcs.normalize_type("JS") == "js" assert mcs.normalize_type(" Java ") == "js" assert mcs.normalize_type("bds") == "bds" assert mcs.normalize_type("bedrock") == "bds" assert mcs.normalize_type("") == "js" # 空值取默认 assert mcs.normalize_type(None, "bds") == "bds" with pytest.raises(mcs.ServerError): mcs.normalize_type("java版") def test_normalize_address_strips_scheme_and_rejects_junk(mcs): assert mcs.normalize_address(" mc.example.com:25565 ") == "mc.example.com:25565" assert mcs.normalize_address("minecraft://mc.example.com") == "mc.example.com" assert mcs.normalize_address("http://mc.example.com/") == "mc.example.com" for bad in ("", " ", "mc example.com", "mc.example.com/foo", ":25565", "host:"): with pytest.raises(mcs.ServerError): mcs.normalize_address(bad) def test_normalize_name_keeps_spaces_and_strips_quotes(mcs): assert mcs.normalize_name("亡者世界") == "亡者世界" assert mcs.normalize_name(" 我 的 服 ") == "我 的 服" assert mcs.normalize_name('"EMDS"') == "EMDS" with pytest.raises(mcs.ServerError): mcs.normalize_name(" ") with pytest.raises(mcs.ServerError): mcs.normalize_name("x" * (mcs.MAX_NAME_LEN + 2)) def test_parse_group_id(mcs): assert mcs.parse_group_id("872490448") == 872490448 assert mcs.parse_group_id(304484502) == 304484502 for bad in ("", " ", "abc", "872490448.0", "0", 1234567890123): with pytest.raises(mcs.ServerError): mcs.parse_group_id(bad) # ───────────────────────── 增 / 改 ───────────────────────── def test_add_writes_file_and_memory(mcs, groups): assert mcs.set_server(872490448, "EMDS", "sansenhoshi.top:29985", "js") is True assert groups[872490448]["EMDS"] == ["sansenhoshi.top:29985", "js"] assert _file(mcs) == {"872490448": {"EMDS": ["sansenhoshi.top:29985", "js"]}} def test_update_same_name_keeps_position(mcs, groups): mcs.set_server(872490448, "EMDS", "a.example.com:1", "js") mcs.set_server(872490448, "星门", "b.example.com:2", "bds") assert mcs.set_server(872490448, "EMDS", "c.example.com:3", "js") is False # 同名 = 更新 assert list(groups[872490448]) == ["EMDS", "星门"] # 顺序不变 assert groups[872490448]["EMDS"] == ["c.example.com:3", "js"] def test_rename_moves_entry(mcs, groups): mcs.set_server(872490448, "旧名", "mc.example.com:25565", "js") assert mcs.set_server(872490448, "新名", "mc.example.com:25565", "js", old_name="旧名") is True assert list(groups[872490448]) == ["新名"] assert _file(mcs)["872490448"]["新名"] == ["mc.example.com:25565", "js"] def test_rename_conflict_is_rejected_without_touching_data(mcs, groups): mcs.set_server(872490448, "A", "a.example.com:1", "js") mcs.set_server(872490448, "B", "b.example.com:2", "js") with pytest.raises(mcs.ServerError): mcs.set_server(872490448, "B", "c.example.com:3", "js", old_name="A") assert groups[872490448] == {"A": ["a.example.com:1", "js"], "B": ["b.example.com:2", "js"]} def test_rename_missing_source_is_rejected(mcs, groups): mcs.set_server(872490448, "A", "a.example.com:1", "js") with pytest.raises(mcs.ServerError): mcs.set_server(872490448, "B", "b.example.com:2", "js", old_name="不存在") assert list(groups[872490448]) == ["A"] def test_invalid_input_does_not_write_file(mcs, groups, tmp_path): with pytest.raises(mcs.ServerError): mcs.set_server(872490448, "A", "a.example.com:1", "不是类型") assert groups == {} assert not (tmp_path / "mc_status_data.json").exists() # ───────────────────────── 删 ───────────────────────── def test_delete_server_and_cleanup_empty_group(mcs, groups): mcs.set_server(872490448, "A", "a.example.com:1", "js") mcs.set_server(872490448, "B", "b.example.com:2", "js") mcs.set_server(304484502, "C", "c.example.com:3", "bds") mcs.delete_server(872490448, "A") assert list(groups[872490448]) == ["B"] mcs.delete_server(872490448, "B") assert 872490448 not in groups # 删空连带删群 assert _file(mcs) == {"304484502": {"C": ["c.example.com:3", "bds"]}} def test_delete_unknown_targets(mcs, groups): with pytest.raises(mcs.ServerError): mcs.delete_server(872490448, "没有这个群") mcs.set_server(872490448, "A", "a.example.com:1", "js") with pytest.raises(mcs.ServerError): mcs.delete_server(872490448, "没有这台") def test_delete_group(mcs, groups): mcs.set_server(872490448, "A", "a.example.com:1", "js") mcs.delete_group(872490448) assert groups == {} with pytest.raises(mcs.ServerError): mcs.delete_group(872490448) # ───────────────────────── 读 ───────────────────────── def test_load_servers_handles_missing_broken_and_partial(mcs, tmp_path): path = tmp_path / "mc_status_data.json" assert mcs.load_servers(str(path)) == {} # 文件不存在 path.write_text("{ 坏掉的 json", encoding="utf-8") assert mcs.load_servers(str(path)) == {} # 坏文件不炸 path.write_text(json.dumps({"8123": {"A": ["a:1", "js"], "B": "不是数组"}}), encoding="utf-8") assert mcs.load_servers(str(path)) == {8123: {"A": ["a:1", "js"]}} def test_snapshot_is_a_copy(mcs, groups): mcs.set_server(872490448, "A", "a.example.com:1", "js") snap = mcs.snapshot() snap[872490448]["A"][0] = "改坏了" snap[872490448]["新"] = ["x:1", "js"] assert groups[872490448] == {"A": ["a.example.com:1", "js"]} def test_lookup(mcs, groups): mcs.set_server(872490448, "A", "a.example.com:1", "js") assert mcs.lookup(872490448, "A") == ["a.example.com:1", "js"] assert mcs.lookup(872490448, "没有这台") is None assert mcs.lookup(999, "A") is None