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>
This commit is contained in:
1 parent
614e1d7e00
commit
a31aab00e8
752 files changed
+6241
-2506
No files matched your search
@@ -0,0 +1,192 @@
|
||||
"""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
|
||||
Reference in new issue
Block a user