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"
|
||||
Reference in new issue
Block a user