- 旧平铺模块(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>
193 lines
6.9 KiB
Python
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
|