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,246 @@
|
||||
"""BF Bot 纯函数助手单测:BF2042 参数解析 / tracker_edge URL 工具 / 文本格式化。
|
||||
|
||||
被测模块(utils/args.py、utils/text.py、services/fetchers/tracker_edge.py、
|
||||
services/fetchers/tracker_bridge.py)顶层不依赖插件包相对导入,故用 importlib
|
||||
按文件路径裸加载(与 test_video_policy.py 同法),不触发插件包 __init__ 的
|
||||
NoneBot 初始化。
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
_PLUGIN_DIR = (
|
||||
Path(__file__).resolve().parents[1] / "hexi" / "plugins" / "nonebot_plugin_bf_bot"
|
||||
)
|
||||
|
||||
|
||||
def _load(name: str, rel_path: str):
|
||||
spec = importlib.util.spec_from_file_location(name, _PLUGIN_DIR / rel_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(scope="module")
|
||||
def args_mod():
|
||||
return _load("bf_args_under_test", "utils/args.py")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def text_mod():
|
||||
return _load("bf_text_under_test", "utils/text.py")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def tracker_edge_mod():
|
||||
return _load("bf_tracker_edge_under_test", "services/fetchers/tracker_edge.py")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def tracker_bridge_mod():
|
||||
return _load("bf_tracker_bridge_under_test", "services/fetchers/tracker_bridge.py")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# utils/args.py — parse_bf2042_args
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_bf2042_args_empty_is_career(args_mod):
|
||||
for raw in ("", " ", None):
|
||||
target = args_mod.parse_bf2042_args(raw)
|
||||
assert (target.view, target.kind, target.player) == ("career", "", "")
|
||||
|
||||
|
||||
def test_bf2042_args_plain_name_is_career(args_mod):
|
||||
target = args_mod.parse_bf2042_args("SomePlayer")
|
||||
assert (target.view, target.kind, target.player) == ("career", "", "SomePlayer")
|
||||
|
||||
|
||||
def test_bf2042_args_name_with_spaces_kept(args_mod):
|
||||
# 首个词不是关键词时,整串(含空格)都是玩家名
|
||||
target = args_mod.parse_bf2042_args("Some Player Name")
|
||||
assert target.view == "career"
|
||||
assert target.player == "Some Player Name"
|
||||
|
||||
|
||||
def test_bf2042_args_prefix_like_name_is_career(args_mod):
|
||||
# 形近但不是关键词的词也算玩家名
|
||||
target = args_mod.parse_bf2042_args("weap")
|
||||
assert (target.view, target.kind, target.player) == ("career", "", "weap")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("head", "kind"),
|
||||
[
|
||||
("武器", "weapon"),
|
||||
("枪械", "weapon"),
|
||||
("weapon", "weapon"),
|
||||
("weapons", "weapon"),
|
||||
("载具", "vehicle"),
|
||||
("vehicle", "vehicle"),
|
||||
("vehicles", "vehicle"),
|
||||
("装备", "gadget"),
|
||||
("道具", "gadget"),
|
||||
("gadget", "gadget"),
|
||||
("gadgets", "gadget"),
|
||||
],
|
||||
)
|
||||
def test_bf2042_args_list_views(args_mod, head, kind):
|
||||
target = args_mod.parse_bf2042_args(f"{head} SomePlayer")
|
||||
assert (target.view, target.kind, target.player) == ("list", kind, "SomePlayer")
|
||||
|
||||
|
||||
def test_bf2042_args_list_view_case_insensitive(args_mod):
|
||||
target = args_mod.parse_bf2042_args("Weapons SomePlayer")
|
||||
assert (target.view, target.kind, target.player) == ("list", "weapon", "SomePlayer")
|
||||
|
||||
|
||||
def test_bf2042_args_gamemode(args_mod):
|
||||
target = args_mod.parse_bf2042_args("模式")
|
||||
assert (target.view, target.kind, target.player) == ("gamemode", "", "")
|
||||
target = args_mod.parse_bf2042_args("模式 SomePlayer")
|
||||
assert (target.view, target.kind, target.player) == ("gamemode", "", "SomePlayer")
|
||||
|
||||
|
||||
def test_bf2042_args_extra_whitespace_stripped(args_mod):
|
||||
target = args_mod.parse_bf2042_args(" 武器 Some Player ")
|
||||
assert (target.view, target.kind, target.player) == (
|
||||
"list",
|
||||
"weapon",
|
||||
"Some Player",
|
||||
)
|
||||
|
||||
|
||||
def test_bf2042_game_names_complete(args_mod):
|
||||
assert set(args_mod.BF_GAME_NAMES) == {"bf3", "bf4", "bf1", "bfv", "bf2042", "bf6"}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# services/fetchers/tracker_edge.py — URL 工具
|
||||
# --------------------------------------------------------------------------- #
|
||||
def test_tracker_edge_norm_strips_query_and_slash(tracker_edge_mod):
|
||||
norm = tracker_edge_mod._norm
|
||||
assert norm("https://api.tracker.gg/x/?") == "https://api.tracker.gg/x"
|
||||
assert norm("https://api.tracker.gg/x") == "https://api.tracker.gg/x"
|
||||
assert norm("https://api.tracker.gg/x/?a=1") == "https://api.tracker.gg/x"
|
||||
|
||||
|
||||
def test_tracker_edge_segment_urls_match_page_requests(tracker_edge_mod):
|
||||
# 页面自己发的是这三个(带尾部 ?);URL 里玩家名须 URL 编码
|
||||
urls = tracker_edge_mod._segment_urls("Some Player")
|
||||
base = "https://api.tracker.gg/api/v2/bf2042/standard/profile/origin/Some%20Player"
|
||||
assert urls["profile"] == base + "?"
|
||||
assert urls["weapon"] == base + "/segments/weapon?"
|
||||
assert urls["vehicle"] == base + "/segments/vehicle?"
|
||||
|
||||
|
||||
def test_tracker_edge_page_url(tracker_edge_mod):
|
||||
url = tracker_edge_mod._page_url("Some Player", "origin")
|
||||
assert url == "https://tracker.gg/bf2042/profile/origin/Some%20Player/overview"
|
||||
|
||||
|
||||
def test_tracker_edge_rollup_shapes(tracker_edge_mod):
|
||||
ok = tracker_edge_mod._rollup(
|
||||
{"profile": {"data": {"x": 1}}, "weapon": {"data": [1]}}, None, ""
|
||||
)
|
||||
assert ok["error"] is None
|
||||
assert ok["weapon"] == [1]
|
||||
assert ok["vehicle"] == [] and ok["soldier"] == []
|
||||
assert ok["gadget"] == [] # 直连通道会填;桥兜底不产 gadget,也要有键
|
||||
|
||||
full = tracker_edge_mod._rollup(
|
||||
{"profile": {"data": {}}, "gadget": [{"type": "gadget"}]}, None, ""
|
||||
)
|
||||
assert full["gadget"] == [{"type": "gadget"}]
|
||||
|
||||
missing = tracker_edge_mod._rollup({}, None, "")
|
||||
assert (missing["error"], missing["profile"]) == ("error", None)
|
||||
|
||||
failed = tracker_edge_mod._rollup({}, "timeout", "等待页面数据超时")
|
||||
assert failed["error"] == "timeout"
|
||||
assert failed["detail"] == "等待页面数据超时"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# services/fetchers/tracker_bridge.py — 任务收集器(纯逻辑)
|
||||
# --------------------------------------------------------------------------- #
|
||||
_BRIDGE_BASE = (
|
||||
"https://api.tracker.gg/api/v2/bf2042/standard/profile/origin/Some%20Player"
|
||||
)
|
||||
_BRIDGE_WANT = {
|
||||
"profile": _BRIDGE_BASE + "?",
|
||||
"weapon": _BRIDGE_BASE + "/segments/weapon?",
|
||||
"vehicle": _BRIDGE_BASE + "/segments/vehicle?",
|
||||
"soldier": _BRIDGE_BASE + "/segments/soldier?",
|
||||
}
|
||||
|
||||
|
||||
def test_bridge_norm_strips_query_and_slash(tracker_bridge_mod):
|
||||
norm = tracker_bridge_mod._norm
|
||||
assert norm("https://x/y/?a=1") == "https://x/y"
|
||||
assert norm("https://x/y/") == "https://x/y"
|
||||
|
||||
|
||||
def test_bridge_collector_feed_matches_only_expected(tracker_bridge_mod):
|
||||
c = tracker_bridge_mod._Collector("Some Player", "origin", _BRIDGE_WANT)
|
||||
assert c.feed(_BRIDGE_BASE + "?extra=1", {"n": 1}) == "profile" # query 不影响
|
||||
assert c.feed(_BRIDGE_BASE + "?", {"n": 2}) is None # 重复不再收
|
||||
other = _BRIDGE_BASE.replace("Some%20Player", "Other")
|
||||
assert c.feed(other + "/segments/weapon?", {}) is None # 别的玩家不命中
|
||||
assert c.feed(_BRIDGE_BASE + "/segments/weapon?", {}) == "weapon"
|
||||
assert c.feed(_BRIDGE_BASE + "/segments/vehicle?", {}) == "vehicle"
|
||||
|
||||
|
||||
def test_bridge_collector_ready_needs_required_and_grace(tracker_bridge_mod):
|
||||
c = tracker_bridge_mod._Collector("Some Player", "origin", _BRIDGE_WANT)
|
||||
for key in ("profile", "weapon", "vehicle"):
|
||||
c.feed(_BRIDGE_WANT[key], {})
|
||||
assert c.done_at is not None # 必收键齐自动开始宽限计时
|
||||
assert not c.ready # 宽限窗口内继续等 soldier
|
||||
c.feed(_BRIDGE_WANT["soldier"], {"s": 1})
|
||||
assert "soldier" in c.captured
|
||||
c.done_at = time.monotonic() - tracker_bridge_mod._GRACE_S - 0.01
|
||||
assert c.ready
|
||||
|
||||
|
||||
def test_bridge_collector_mark_done_needs_all_required(tracker_bridge_mod):
|
||||
c = tracker_bridge_mod._Collector("Some Player", "origin", _BRIDGE_WANT)
|
||||
c.mark_done()
|
||||
assert c.done_at is not None
|
||||
c.done_at = time.monotonic() - tracker_bridge_mod._GRACE_S - 0.01
|
||||
assert not c.ready # 宽限过了也没用:必收键没齐
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# utils/text.py — convert_to_hours / add_commas
|
||||
# --------------------------------------------------------------------------- #
|
||||
@pytest.mark.parametrize(
|
||||
("raw", "hours"),
|
||||
[
|
||||
("1 day, 1:49:15", 25.82), # bfv 实测格式
|
||||
("1:56:55", 1.95),
|
||||
("0:06:41", 0.11),
|
||||
("14:11:36", 14.19), # bf1 实测格式
|
||||
("14 days, 5:52:29", 341.87), # bf6 实测格式
|
||||
('"1 day, 1:49:15"', 25.82), # 兼容带引号包裹
|
||||
(" 1:00:00 ", 1.0), # 兼容前后空白
|
||||
],
|
||||
)
|
||||
def test_convert_to_hours(text_mod, raw, hours):
|
||||
assert text_mod.convert_to_hours(raw) == hours
|
||||
|
||||
|
||||
def test_convert_to_hours_days(text_mod):
|
||||
assert text_mod.convert_to_hours("2 days, 0:00:00") == 48.0
|
||||
|
||||
|
||||
def test_add_commas(text_mod):
|
||||
assert text_mod.add_commas("1234567") == "1,234,567"
|
||||
assert text_mod.add_commas("123") == "123"
|
||||
assert text_mod.add_commas_with_padding("0", 7) == "0,000,000"
|
||||
assert text_mod.add_commas_with_padding(145674, 7) == "0,145,674"
|
||||
@@ -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
|
||||
@@ -0,0 +1,429 @@
|
||||
"""BF2042 直连通道(tracker_api)与通道调度(tracker_edge)单测。
|
||||
|
||||
两个模块都按文件裸加载(tests/test_bf_bot_repository.py 同法,不触发插件包
|
||||
__init__ 的 NoneBot 初始化);**全部用例零真实网络**——令牌端点、API 请求
|
||||
分别用假 ``_do_refresh`` / 假 ``cr.Session`` / 假子模块替换,令牌文件一律
|
||||
指到 tmp_path,绝不碰 hexi/data 下的真实令牌与绑定库。
|
||||
"""
|
||||
|
||||
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_bf_bot"
|
||||
)
|
||||
|
||||
|
||||
def _load(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
|
||||
|
||||
|
||||
def _make_pkg(name: str, path: Path):
|
||||
pkg = types.ModuleType(name)
|
||||
pkg.__path__ = [str(path)] # type: ignore[attr-defined]
|
||||
sys.modules[name] = pkg
|
||||
return pkg
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# tracker_api — 令牌存储 / 引导提取 / 刷新链 / HTTP 错误映射
|
||||
# --------------------------------------------------------------------------- #
|
||||
_API_PATH = _PLUGIN_DIR / "services" / "fetchers" / "tracker_api.py"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def api_mod(tmp_path, monkeypatch):
|
||||
"""裸加载 tracker_api;令牌路径指到 tmp_path 下的文件。"""
|
||||
module = _load("bf_tracker_api_under_test", _API_PATH)
|
||||
token_path = tmp_path / "trn_tokens.json"
|
||||
monkeypatch.setattr(module, "_token_path", lambda: token_path)
|
||||
return module
|
||||
|
||||
|
||||
def test_decode_tokens_blob_latin1():
|
||||
module = _load("bf_tracker_api_decode1", _API_PATH)
|
||||
payload = {"access_token": "a1", "refresh_token": "r1"}
|
||||
raw = (
|
||||
b"\x00_https://tracker.gg\x00\x01auth.tokens"
|
||||
+ b"\x01"
|
||||
+ json.dumps(payload).encode("latin-1")
|
||||
+ b"\x00garbage"
|
||||
)
|
||||
assert module._decode_tokens_blob(raw) == payload
|
||||
|
||||
|
||||
def test_decode_tokens_blob_utf16_and_garbage():
|
||||
module = _load("bf_tracker_api_decode2", _API_PATH)
|
||||
payload = {"access_token": "a2", "refresh_token": "r2"}
|
||||
raw = (
|
||||
"auth.tokens".encode("utf-16-le")
|
||||
+ b"\x00"
|
||||
+ json.dumps(payload).encode("utf-16-le")
|
||||
)
|
||||
assert module._decode_tokens_blob(raw) == payload
|
||||
assert module._decode_tokens_blob(b"\x01no keys here") is None
|
||||
|
||||
|
||||
def test_extract_tokens_from_dir(tmp_path):
|
||||
module = _load("bf_tracker_api_extract", _API_PATH)
|
||||
|
||||
assert module._extract_tokens_from_dir(tmp_path / "missing") is None
|
||||
|
||||
garbage = tmp_path / "garbage"
|
||||
garbage.mkdir()
|
||||
(garbage / "000003.log").write_bytes(b"nothing to see")
|
||||
assert module._extract_tokens_from_dir(garbage) is None
|
||||
|
||||
hit = tmp_path / "hit"
|
||||
hit.mkdir()
|
||||
(hit / "000003.log").write_bytes(
|
||||
b"auth.tokens\x01"
|
||||
+ json.dumps({"access_token": "a", "refresh_token": "r"}).encode()
|
||||
)
|
||||
found = module._extract_tokens_from_dir(hit)
|
||||
assert found and found["refresh_token"] == "r"
|
||||
|
||||
|
||||
def test_token_fresh(api_mod):
|
||||
assert api_mod._token_fresh({"access_token": "x", "expires_at": 2000.0}, now=1000.0)
|
||||
assert not api_mod._token_fresh(
|
||||
{"access_token": "x", "expires_at": 500.0}, now=1000.0
|
||||
)
|
||||
assert not api_mod._token_fresh({"access_token": "x"})
|
||||
assert not api_mod._token_fresh({"expires_at": 9999.0})
|
||||
|
||||
|
||||
def test_normalize_token_payload_keeps_prev_refresh(api_mod):
|
||||
fresh = api_mod._normalize_token_payload(
|
||||
{"access_token": "a", "expires_in": 3600}, "old"
|
||||
)
|
||||
assert fresh["access_token"] == "a"
|
||||
assert fresh["refresh_token"] == "old" # 未轮换时沿用旧值
|
||||
assert fresh["expires_at"] > api_mod.time.time() + 3000
|
||||
|
||||
|
||||
def test_ensure_access_token_fresh_skips_refresh(api_mod, monkeypatch):
|
||||
api_mod._token_path().write_text(
|
||||
json.dumps({"access_token": "good", "refresh_token": "r", "expires_at": 9e9}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
def _boom(*_a, **_k):
|
||||
raise AssertionError("fresh 令牌不应触发刷新")
|
||||
|
||||
monkeypatch.setattr(api_mod, "_do_refresh", _boom)
|
||||
assert api_mod.ensure_access_token() == "good"
|
||||
|
||||
|
||||
def test_ensure_access_token_refreshes_and_persists(api_mod, monkeypatch):
|
||||
api_mod._token_path().write_text(
|
||||
json.dumps({"access_token": "stale", "refresh_token": "r1", "expires_at": 1.0}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(api_mod, "_bootstrap_tokens", lambda: None)
|
||||
seen: list[str] = []
|
||||
|
||||
def _fake_refresh(refresh_token: str) -> dict:
|
||||
seen.append(refresh_token)
|
||||
return {
|
||||
"access_token": "new",
|
||||
"refresh_token": "r2",
|
||||
"expires_at": api_mod.time.time() + 3000,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(api_mod, "_do_refresh", _fake_refresh)
|
||||
assert api_mod.ensure_access_token() == "new"
|
||||
assert seen == ["r1"]
|
||||
saved = json.loads(api_mod._token_path().read_text(encoding="utf-8"))
|
||||
assert saved["refresh_token"] == "r2" # 轮换后的令牌已落盘
|
||||
assert saved["access_token"] == "new"
|
||||
|
||||
|
||||
def test_ensure_access_token_bootstrap_when_file_token_rejected(api_mod, monkeypatch):
|
||||
api_mod._token_path().write_text(
|
||||
json.dumps(
|
||||
{"access_token": "stale", "refresh_token": "dead", "expires_at": 1.0}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
api_mod, "_bootstrap_tokens", lambda: {"refresh_token": "from-browser"}
|
||||
)
|
||||
|
||||
def _fake_refresh(refresh_token: str) -> dict:
|
||||
if refresh_token == "dead":
|
||||
raise api_mod.TrnAuthError("invalid_grant")
|
||||
return {
|
||||
"access_token": "ok",
|
||||
"refresh_token": "rt3",
|
||||
"expires_at": api_mod.time.time() + 3000,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(api_mod, "_do_refresh", _fake_refresh)
|
||||
assert api_mod.ensure_access_token() == "ok"
|
||||
|
||||
|
||||
def test_ensure_access_token_raises_without_any_source(api_mod, monkeypatch):
|
||||
monkeypatch.setattr(api_mod, "_bootstrap_tokens", lambda: None)
|
||||
with pytest.raises(api_mod.TrnAuthError):
|
||||
api_mod.ensure_access_token()
|
||||
|
||||
|
||||
class _FakeResp:
|
||||
def __init__(self, status: int, payload=None, text: str = ""):
|
||||
self.status_code = status
|
||||
self._payload = payload
|
||||
self.text = text
|
||||
|
||||
def json(self):
|
||||
if self._payload is None:
|
||||
raise ValueError("not json")
|
||||
return self._payload
|
||||
|
||||
|
||||
class _FakeSession:
|
||||
"""按 URL 关键词路由的假会话;profile 无关键词时兜底到 'profile'。"""
|
||||
|
||||
def __init__(self, routes: dict):
|
||||
self._routes = routes
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_exc):
|
||||
return False
|
||||
|
||||
def get(self, url: str, headers=None, timeout=None):
|
||||
for key, resp in self._routes.items():
|
||||
if key == "profile":
|
||||
continue
|
||||
if len(key) > len("segments/") and key in url:
|
||||
return resp
|
||||
return self._routes["profile"]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_session(api_mod, monkeypatch):
|
||||
def _install(routes: dict):
|
||||
fake_cr = types.SimpleNamespace(Session=lambda **kw: _FakeSession(routes))
|
||||
monkeypatch.setattr(api_mod, "cr", fake_cr)
|
||||
monkeypatch.setattr(api_mod, "ensure_access_token", lambda force=False: "tok")
|
||||
|
||||
return _install
|
||||
|
||||
|
||||
def test_fetch_sync_maps_404_to_not_found(api_mod, fake_session):
|
||||
fake_session(
|
||||
{"profile": _FakeResp(404, {"errors": [{"message": "We could not find..."}]})}
|
||||
)
|
||||
out = api_mod._fetch_sync("Ghost", "origin")
|
||||
assert out["error"] == "not_found"
|
||||
assert "could not find" in out["detail"]
|
||||
assert out["profile"] is None
|
||||
|
||||
|
||||
def test_fetch_sync_maps_403_to_auth(api_mod, fake_session):
|
||||
fake_session({"profile": _FakeResp(403, None, text="<html>blocked</html>")})
|
||||
out = api_mod._fetch_sync("X", "origin")
|
||||
assert out["error"] == "auth"
|
||||
|
||||
|
||||
def test_fetch_sync_success_fills_all_segments(api_mod, fake_session):
|
||||
fake_session(
|
||||
{
|
||||
"profile": _FakeResp(
|
||||
200, {"data": {"platformInfo": {"platformUserHandle": "P"}}}
|
||||
),
|
||||
"segments/weapon": _FakeResp(200, {"data": [{"type": "weapon"}]}),
|
||||
"segments/vehicle": _FakeResp(200, {"data": [{"type": "vehicle"}]}),
|
||||
"segments/gadget": _FakeResp(200, {"data": [{"type": "gadget"}]}),
|
||||
"segments/soldier": _FakeResp(200, {"data": [{"type": "soldier"}]}),
|
||||
}
|
||||
)
|
||||
out = api_mod._fetch_sync("P", "origin")
|
||||
assert out["error"] is None
|
||||
assert out["profile"]["data"]["platformInfo"]["platformUserHandle"] == "P"
|
||||
assert out["weapon"] == [{"type": "weapon"}]
|
||||
assert out["gadget"] == [{"type": "gadget"}]
|
||||
assert out["soldier"] == [{"type": "soldier"}]
|
||||
|
||||
|
||||
def test_fetch_sync_segment_failure_degrades_not_aborts(api_mod, fake_session):
|
||||
fake_session(
|
||||
{
|
||||
"profile": _FakeResp(200, {"data": {"platformInfo": {}}}),
|
||||
"segments/weapon": _FakeResp(500, None, text="err"),
|
||||
}
|
||||
)
|
||||
out = api_mod._fetch_sync("P", "origin")
|
||||
assert out["error"] is None # profile 成功即算成功
|
||||
assert out["weapon"] == [] # 失败的段退化为空,不拖垮整次取数
|
||||
|
||||
|
||||
async def test_fetch_bf2042_direct_retries_once_on_auth(api_mod, monkeypatch):
|
||||
calls: list[bool] = []
|
||||
|
||||
def _fake_fetch_sync(name, platform, *, force_refresh=False):
|
||||
calls.append(force_refresh)
|
||||
if not force_refresh:
|
||||
return {**api_mod._empty_result(), "error": "auth", "detail": "HTTP 403"}
|
||||
return {**api_mod._empty_result(), "profile": {"data": {}}}
|
||||
|
||||
monkeypatch.setattr(api_mod, "_fetch_sync", _fake_fetch_sync)
|
||||
out = await api_mod.fetch_bf2042_direct("P", "origin")
|
||||
assert calls == [False, True] # 鉴权失败 → 强制刷新重试一次
|
||||
assert out["error"] is None
|
||||
|
||||
|
||||
async def test_fetch_bf2042_direct_no_retry_on_success(api_mod, monkeypatch):
|
||||
calls: list[bool] = []
|
||||
|
||||
def _fake_fetch_sync(name, platform, *, force_refresh=False):
|
||||
calls.append(force_refresh)
|
||||
return {**api_mod._empty_result(), "profile": {"data": {}}}
|
||||
|
||||
monkeypatch.setattr(api_mod, "_fetch_sync", _fake_fetch_sync)
|
||||
out = await api_mod.fetch_bf2042_direct("P", "origin")
|
||||
assert calls == [False]
|
||||
assert out["error"] is None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# tracker_edge.fetch_bf2042_segments — 直连优先 / 桥兜底调度
|
||||
# --------------------------------------------------------------------------- #
|
||||
_DISPATCH_PKG = "bf_dispatch_testpkg"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dispatch(monkeypatch):
|
||||
"""包骨架 + 真 tracker_edge + 假 tracker_api / tracker_bridge 子模块。"""
|
||||
_make_pkg(_DISPATCH_PKG, _PLUGIN_DIR)
|
||||
_make_pkg(f"{_DISPATCH_PKG}.services", _PLUGIN_DIR / "services")
|
||||
fetchers_pkg = _make_pkg(
|
||||
f"{_DISPATCH_PKG}.services.fetchers", _PLUGIN_DIR / "services" / "fetchers"
|
||||
)
|
||||
edge = _load(
|
||||
f"{_DISPATCH_PKG}.services.fetchers.tracker_edge",
|
||||
_PLUGIN_DIR / "services" / "fetchers" / "tracker_edge.py",
|
||||
)
|
||||
api = types.ModuleType(f"{_DISPATCH_PKG}.services.fetchers.tracker_api")
|
||||
bridge = types.ModuleType(f"{_DISPATCH_PKG}.services.fetchers.tracker_bridge")
|
||||
sys.modules[api.__name__] = api
|
||||
sys.modules[bridge.__name__] = bridge
|
||||
# 直接挂到父包上,让 edge 里 `from . import tracker_api` 稳定命中
|
||||
fetchers_pkg.tracker_api = api
|
||||
fetchers_pkg.tracker_bridge = bridge
|
||||
|
||||
# 关掉节流(测试里不等 3s/20s)
|
||||
monkeypatch.setattr(edge, "_DIRECT_MIN_INTERVAL_S", 0.0)
|
||||
monkeypatch.setattr(edge, "_MIN_INTERVAL_S", 0.0)
|
||||
monkeypatch.setattr(edge, "_last_direct_monotonic", 0.0)
|
||||
monkeypatch.setattr(edge, "_last_fetch_monotonic", 0.0)
|
||||
return edge, api, bridge
|
||||
|
||||
|
||||
def _direct_ok(**over):
|
||||
base = {
|
||||
"profile": {"data": {"platformInfo": {}}},
|
||||
"weapon": [{"type": "weapon"}],
|
||||
"vehicle": [],
|
||||
"gadget": [{"type": "gadget"}],
|
||||
"soldier": [],
|
||||
"error": None,
|
||||
"detail": "",
|
||||
}
|
||||
base.update(over)
|
||||
return base
|
||||
|
||||
|
||||
async def test_dispatch_direct_ok_never_touches_bridge(dispatch):
|
||||
edge, api, bridge = dispatch
|
||||
|
||||
async def _direct(name, platform):
|
||||
return _direct_ok()
|
||||
|
||||
async def _bridge(**_kw):
|
||||
raise AssertionError("直连成功时不应回退扩展桥")
|
||||
|
||||
api.fetch_bf2042_direct = _direct
|
||||
bridge.fetch_via_extension = _bridge
|
||||
out = await edge.fetch_bf2042_segments("P", "origin")
|
||||
assert out["error"] is None
|
||||
assert out["weapon"] == [{"type": "weapon"}]
|
||||
assert out["gadget"] == [{"type": "gadget"}]
|
||||
|
||||
|
||||
async def test_dispatch_not_found_is_final(dispatch):
|
||||
edge, api, bridge = dispatch
|
||||
|
||||
async def _direct(name, platform):
|
||||
return _direct_ok(
|
||||
profile=None,
|
||||
weapon=[],
|
||||
gadget=[],
|
||||
error="not_found",
|
||||
detail="no such player",
|
||||
)
|
||||
|
||||
async def _bridge(**_kw):
|
||||
raise AssertionError("not_found 是确定性结果,不应回退扩展桥")
|
||||
|
||||
api.fetch_bf2042_direct = _direct
|
||||
bridge.fetch_via_extension = _bridge
|
||||
out = await edge.fetch_bf2042_segments("Ghost", "origin")
|
||||
assert out["error"] == "not_found"
|
||||
assert out["detail"] == "no such player"
|
||||
|
||||
|
||||
async def test_dispatch_falls_back_to_bridge_on_direct_error(dispatch):
|
||||
edge, api, bridge = dispatch
|
||||
calls = []
|
||||
|
||||
async def _direct(name, platform):
|
||||
return _direct_ok(
|
||||
profile=None, weapon=[], gadget=[], error="error", detail="HTTP 500"
|
||||
)
|
||||
|
||||
async def _bridge(**kw):
|
||||
calls.append(kw)
|
||||
return (
|
||||
{"profile": {"data": {"platformInfo": {}}}, "weapon": {"data": [1]}},
|
||||
None,
|
||||
"",
|
||||
)
|
||||
|
||||
api.fetch_bf2042_direct = _direct
|
||||
bridge.fetch_via_extension = _bridge
|
||||
out = await edge.fetch_bf2042_segments("P", "origin")
|
||||
assert len(calls) == 1
|
||||
assert out["error"] is None
|
||||
assert out["weapon"] == [1]
|
||||
|
||||
|
||||
async def test_dispatch_merges_details_when_both_fail(dispatch):
|
||||
edge, api, bridge = dispatch
|
||||
|
||||
async def _direct(name, platform):
|
||||
return _direct_ok(
|
||||
profile=None, weapon=[], gadget=[], error="auth", detail="令牌失效"
|
||||
)
|
||||
|
||||
async def _bridge(**kw):
|
||||
return {}, "timeout", "等待页面数据超时"
|
||||
|
||||
api.fetch_bf2042_direct = _direct
|
||||
bridge.fetch_via_extension = _bridge
|
||||
out = await edge.fetch_bf2042_segments("P", "origin")
|
||||
assert out["error"] == "timeout"
|
||||
assert "等待页面数据超时" in out["detail"]
|
||||
assert "令牌失效" in out["detail"]
|
||||
Reference in new issue
Block a user