Files
HeXi/tests/test_bf_bot_repository.py
T
sansenhoshiandClaude Code a31aab00e8 refactor(bf-bot): 插件重构为 handlers/services/utils 分层,资源归入 res/
- 旧平铺模块(data/data_utils/database_op/image_builder*/get_bf6_data/
  gametools_bf6/bf6_data/img_utils)拆分为 handlers(绑定/查询)、
  services(fetchers/processing/query)、utils(渲染/参数)分层;
  新增 repository.py / config.py / paths.py
- 字体/图片/模板资源迁入 res/,新增 BF2042 卡面素材(bg/object_icon 等)
- browser_ext/:BF2042 扩展捕获桥(WS 直连兜底通道)源码入库
- 新增 hexi/web/src/plugins/bf_bot 前端管理页
- 新增 tests:test_bf_bot_helpers / repository / tracker_api

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-13 17:53:06 +08:00

193 lines
6.9 KiB
Python

"""BF Bot 绑定库 (repository.py) 单测:建库幂等 / 列类型比较 / UserManager 回环。
repository.py 顶层只依赖标准库 + 同包 paths.py,用 importlib 造一次性包骨架
(与 test_mc_servers.py 同法)加载,避免触发插件包 __init__ 的 NoneBot 初始化。
**所有用例都显式传 tmp_path 下的库文件**,绝不碰 hexi/data 下的真实绑定库。
"""
import importlib.util
import sqlite3
import sys
import types
from pathlib import Path
import pytest
_PLUGIN_DIR = (
Path(__file__).resolve().parents[1] / "hexi" / "plugins" / "nonebot_plugin_bf_bot"
)
_PKG = "bf_repo_testpkg"
def _load_by_path(name: str, path: Path):
spec = importlib.util.spec_from_file_location(name, path)
assert spec and spec.loader
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
@pytest.fixture
def repo(tmp_path):
"""一次性包骨架 + 全新 repository 模块 + tmp_path 下的空库文件。"""
pkg = types.ModuleType(_PKG)
pkg.__path__ = [str(_PLUGIN_DIR)] # type: ignore[attr-defined]
sys.modules[_PKG] = pkg
_load_by_path(f"{_PKG}.paths", _PLUGIN_DIR / "paths.py")
module = _load_by_path(f"{_PKG}.repository", _PLUGIN_DIR / "repository.py")
return module, str(tmp_path / "user_data.db")
def test_paths_defaults_point_into_hexi_data(tmp_path, repo):
"""默认库路径/头像/缓存必须都在 hexi/data 下,不再落插件源码目录。"""
module, _ = repo
paths = sys.modules[f"{_PKG}.paths"]
hexi_root = _PLUGIN_DIR.parents[1]
assert paths.DATA_DIR == hexi_root / "data" / "nonebot_plugin_bf_bot"
assert paths.DB_PATH.parent == paths.DATA_DIR
assert paths.RES_DIR == _PLUGIN_DIR / "res"
assert module.TableManager().db_path == str(paths.DB_PATH)
def test_initialize_database_idempotent(repo):
module, db_path = repo
manager = module.DatabaseManager(db_path=db_path)
assert manager.initialize_database() is True
assert manager.initialize_database() is True # 第二次建表仍是幂等成功
conn = sqlite3.connect(db_path)
tables = {
row[0]
for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
}
# 四张业务表齐全;sqlite_sequence 属内部表,完整性校验会忽略
assert {
"users",
"designation",
"medals",
"query_record",
} <= tables
assert manager._verify_database_integrity(conn) is True
conn.close()
def test_initialize_database_failure_keeps_file(repo, tmp_path):
"""建库失败只返回 False,不得删库/抛异常(删库会连带丢绑定)。"""
module, _ = repo
bad_path = str(tmp_path / "不存在的目录" / "x.db") # 父目录不存在,打不开
assert module.DatabaseManager(db_path=bad_path).initialize_database() is False
def test_type_head(repo):
module, _ = repo
type_head = module.DatabaseManager._type_head
assert type_head("INTEGER PRIMARY KEY AUTOINCREMENT") == "INTEGER"
assert type_head("text") == "TEXT"
assert type_head("") == ""
assert type_head(None) == ""
def test_compare_columns(repo):
module, _ = repo
manager = module.DatabaseManager(db_path=":memory:")
# PRAGMA 返回值不带约束,只比类型首词
assert manager._compare_columns(
[("id", "INTEGER"), ("qq_id", "TEXT")],
[("id", "INTEGER PRIMARY KEY AUTOINCREMENT"), ("qq_id", "TEXT NOT NULL")],
)
# 列名不同 / 列数不同都要判 False
assert not manager._compare_columns([("id", "INTEGER")], [("idx", "INTEGER")])
assert not manager._compare_columns(
[("id", "INTEGER")], [("id", "INTEGER"), ("qq_id", "TEXT")]
)
# 类型首词不同也判 False
assert not manager._compare_columns([("id", "INTEGER")], [("id", "TEXT")])
def test_user_roundtrip(repo):
module, db_path = repo
module.DatabaseManager(db_path=db_path).initialize_database()
manager = module.UserManager(db_path=db_path)
assert manager.get_user_by_qq("10001") is None
row_id = manager.add_user("10001", "PlayerA", "pid-1", "oid-1", "[]", "[]")
assert row_id > 0
record = manager.get_user_by_qq("10001")
assert record is not None
assert record["ea_player_name"] == "PlayerA"
assert record["ea_player_id"] == "pid-1"
assert manager.get_user_by_ea_id("oid-1")["qq_id"] == "10001"
# 换绑
assert manager.update_user("10001", "PlayerB", "pid-2", "oid-2", "[]", "[]") == 1
assert manager.get_user_by_qq("10001")["ea_player_name"] == "PlayerB"
assert manager.get_user_by_ea_id("oid-1") is None
assert manager.get_user_by_ea_id("oid-2")["qq_id"] == "10001"
# 解绑:删到 0 行;重复删除返回 0
assert manager.delete_user_by_qq("10001") == 1
assert manager.get_user_by_qq("10001") is None
assert manager.delete_user_by_qq("10001") == 0
def test_user_ea_unique_constraint(repo):
"""EA UNIQUE 是最终的并发兜底;绑定入口需先 get_user_by_ea_id 预检。"""
module, db_path = repo
module.DatabaseManager(db_path=db_path).initialize_database()
manager = module.UserManager(db_path=db_path)
manager.add_user("10001", "PlayerA", "pid-1", "oid-1", "[]", "[]")
with pytest.raises(sqlite3.IntegrityError):
manager.add_user("10002", "PlayerA", "pid-9", "oid-1", "[]", "[]")
def test_list_users_newest_first(repo):
module, db_path = repo
module.DatabaseManager(db_path=db_path).initialize_database()
manager = module.UserManager(db_path=db_path)
manager.add_user("10001", "PlayerA", "pid-1", "oid-1", "[]", "[]")
manager.add_user("10002", "PlayerB", "pid-2", "oid-2", "[]", "[]")
users = manager.list_users()
assert [u["qq_id"] for u in users] == ["10002", "10001"]
assert manager.count_users() == 2
# Web 管理页需要的字段都在
for key in (
"qq_id",
"ea_player_name",
"ea_player_id",
"ea_user_id",
"designation",
"create_time",
):
assert key in users[0]
def test_list_users_pagination(repo):
"""Web 页分页:LIMIT/OFFSET 翻页不重不漏,顺序与全量一致(时间倒序 + id 兜底)。"""
module, db_path = repo
module.DatabaseManager(db_path=db_path).initialize_database()
manager = module.UserManager(db_path=db_path)
for i in range(7):
manager.add_user(f"1000{i}", f"Player{i}", f"pid-{i}", f"oid-{i}", "[]", "[]")
assert manager.count_users() == 7
assert [u["qq_id"] for u in manager.list_users(limit=3, offset=0)] == [
"10006",
"10005",
"10004",
]
assert [u["qq_id"] for u in manager.list_users(limit=3, offset=3)] == [
"10003",
"10002",
"10001",
]
assert [u["qq_id"] for u in manager.list_users(limit=3, offset=6)] == ["10000"]
# 越界页返回空;不带 limit 仍是全量
assert manager.list_users(limit=3, offset=9) == []
assert len(manager.list_users()) == 7