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