Files
HeXi/tests/test_bf_bot_helpers.py
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

247 lines
9.2 KiB
Python

"""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"