246 lines
9.2 KiB
Python
246 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"
|