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:
sansenhoshiandClaude Code committed 2026-10-13 17:53:06 +08:00
1 parent 614e1d7e00
commit a31aab00e8
752 files changed
+6241 -2506

No files matched your search

+246
View File
@@ -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"
+192
View File
@@ -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
+429
View File
@@ -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"]