"""Steam 信息卡解析层 (steam.py 纯函数) 单元测试
steam.py 只依赖 httpx/bs4/nonebot.log + 相对导入的 config/models,不碰
NoneBot 运行时;仿照 test_mc_servers.py 造一棵一次性包骨架(`__path__` 指向
插件目录),相对导入就能自动落到真实文件上,且不触发插件包 __init__.py。
测的是资料页 HTML 与社区 ?xml=1 的解析、状态/风控文案整理,全离线。
样本取自真实抓取结果(76561199135038179 公开资料)裁剪而成。
"""
import importlib.util
import sys
import types
from pathlib import Path
import pytest
_PLUGIN_DIR = (
Path(__file__).resolve().parents[1]
/ "hexi"
/ "plugins"
/ "nonebot_plugin_steam_info"
)
_PKG = "steam_parse_testpkg"
PROFILE_XML = """
76561199135038179
online
public
0
None
0
January 29, 2021
0.0
"""
PROFILE_XML_IN_GAME = """
in-game
public
January 29, 2021
"""
PROFILE_XML_PRIVATE = """
76561199135038179
private
"""
PROFILE_XML_BANNED = """
offline
public
1
Trade Banned
1
7.5
"""
PROFILE_HTML = """
"""
def _load_by_path(name: str, path: Path):
spec = importlib.util.spec_from_file_location(name, path)
module = importlib.util.module_from_spec(spec)
sys.modules[name] = module
spec.loader.exec_module(module)
return module
_STUB_MODULES = ("hexi", "hexi.core", "hexi.core.rate_limit")
def _install_rate_limit_stub() -> dict:
"""steam.py 顶层 `from hexi.core.rate_limit import acquire`,而 hexi.core 包
__init__ 会触发 NoneBot 运行时初始化;纯函数测试用不到限流,塞一个 no-op
acquire 并屏蔽包 __init__(沿用 test_rate_limit.py 的隔离思路)。返回现场用于还原。
"""
saved = {name: sys.modules.get(name) for name in _STUB_MODULES}
hexi_dir = _PLUGIN_DIR.parents[1]
hexi = types.ModuleType("hexi")
hexi.__path__ = [str(hexi_dir)] # type: ignore[attr-defined]
core = types.ModuleType("hexi.core")
core.__path__ = [str(hexi_dir / "core")] # type: ignore[attr-defined]
rate_limit = types.ModuleType("hexi.core.rate_limit")
async def _acquire(*_args, **_kwargs) -> bool:
return True
rate_limit.acquire = _acquire # type: ignore[attr-defined]
sys.modules["hexi"] = hexi
sys.modules["hexi.core"] = core
sys.modules["hexi.core.rate_limit"] = rate_limit
return saved
def _restore_modules(saved: dict) -> None:
for name, module in saved.items():
if module is None:
sys.modules.pop(name, None)
else:
sys.modules[name] = module
@pytest.fixture(scope="module")
def steam():
"""一次性包骨架 + 真实 steam.py(不触发插件 __init__.py)"""
pkg = types.ModuleType(_PKG)
pkg.__path__ = [str(_PLUGIN_DIR)] # type: ignore[attr-defined]
sys.modules[_PKG] = pkg
saved = _install_rate_limit_stub()
try:
yield _load_by_path(f"{_PKG}.steam", _PLUGIN_DIR / "steam.py")
finally:
_restore_modules(saved)
for name in list(sys.modules):
if name == _PKG or name.startswith(f"{_PKG}."):
sys.modules.pop(name, None)
def test_parse_profile_xml_full(steam):
data = steam.parse_profile_xml(PROFILE_XML)
assert data["member_since"] == "2021-01-29"
assert data["online_state"] == "online"
assert data["state_message"] == "Online"
assert data["vac_banned"] is False
assert data["trade_ban_state"] is None
assert data["limited_account"] is False
assert data["hours_2wk"] == 0.0
assert data["privacy_state"] == "public"
def test_parse_profile_xml_in_game(steam):
data = steam.parse_profile_xml(PROFILE_XML_IN_GAME)
assert data["online_state"] == "in-game"
assert data["state_message"] == "In-Game: Team Fortress 2"
def test_parse_profile_xml_private_profile(steam):
data = steam.parse_profile_xml(PROFILE_XML_PRIVATE)
assert data["privacy_state"] == "private"
assert data["member_since"] is None
assert data["online_state"] is None
assert data["hours_2wk"] is None
assert data["vac_banned"] is False
def test_parse_profile_xml_bans_and_hours(steam):
data = steam.parse_profile_xml(PROFILE_XML_BANNED)
assert data["vac_banned"] is True
assert data["trade_ban_state"] == "Trade Banned"
assert data["limited_account"] is True
assert data["hours_2wk"] == 7.5
def test_parse_profile_xml_invalid_text(steam):
assert steam.parse_profile_xml("not xml at all") == {}
def test_member_since_localized_fallback(steam):
"""本地化/未知格式:能识别的归一化,认不出就原样保留"""
xml_zh = "2021年1月29日"
assert steam.parse_profile_xml(xml_zh)["member_since"] == "2021-01-29"
xml_odd = "29/01/2021"
assert steam.parse_profile_xml(xml_odd)["member_since"] == "29/01/2021"
def test_parse_profile_extras(steam):
data = steam.parse_profile_extras(PROFILE_HTML)
assert data["level"] == 7
# 库存是空占位被跳过;千位逗号被去掉
assert data["counts"] == {"游戏": 1234, "评测": 2, "徽章": 7, "好友": 12}
def test_parse_profile_extras_missing(steam):
data = steam.parse_profile_extras("无资料")
assert data["level"] is None
assert data["counts"] == {}
IN_GAME_HTML = """
"""
def test_build_profile_status_in_game(steam):
# XML 两种写法都要认:"In-Game: X" 和 "In-Game
X"
status = steam.build_profile_status(
"in-game", "In-Game: Team Fortress 2", "public"
)
assert status["text"] == "Team Fortress 2"
assert status["kind"] == "in-game"
assert status["game_name_en"] == "Team Fortress 2"
status = steam.build_profile_status("in-game", "In-Game
VRChat", "public")
assert status["text"] == "VRChat"
assert status["game_name_en"] == "VRChat"
def test_parse_in_game_name(steam):
assert steam.parse_in_game_name(IN_GAME_HTML) == "Subnautica 2:异星水域"
assert steam.parse_in_game_name(PROFILE_HTML) is None
def test_build_profile_status_prefers_page_in_game(steam):
"""资料页「当前正在游戏」优先于 XML(XML 会滞后,且资料页已本地化)"""
status = steam.build_profile_status(
"online", "Online", "public", current_game="Subnautica 2:异星水域"
)
assert status == {"text": "Subnautica 2:异星水域", "kind": "in-game"}
def test_build_profile_status_online_and_offline(steam):
# 默认英文状态换中文;自定义状态原样保留
assert steam.build_profile_status("online", "Online", "public") == {
"text": "在线",
"kind": "online",
}
assert steam.build_profile_status("online", "摸鱼中", "public") == {
"text": "摸鱼中",
"kind": "online",
}
assert steam.build_profile_status("offline", None, "public") == {
"text": "离线",
"kind": "offline",
}
def test_build_profile_status_private_overrides(steam):
status = steam.build_profile_status("offline", None, "private")
assert status == {"text": "资料私密,部分信息不可见", "kind": "private"}
BACKDROP_CSS = """
"""
BACKDROP_CSS_TIGHT = """
"""
BACKDROP_CSS_WITH_DECOY = """
"""
BACKDROP_ANIMATED = """
"""
AVATAR_FRAME = """
"""
AVATAR_FRAME_IMG_ONLY = """
"""
def test_background_css_variants(steam):
assert steam.parse_profile_background_url(BACKDROP_CSS) == (
"https://shared.fastly.steamstatic.com/community_assets/images/items/2458530/abc.jpg"
)
assert steam.parse_profile_background_url(BACKDROP_CSS_TIGHT) == (
"https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/items/123/def.png"
)
def test_background_prefers_community_item(steam):
"""页面里其他 url() 不一定是背景,优先挑 /items/ 的社区物品图"""
assert steam.parse_profile_background_url(BACKDROP_CSS_WITH_DECOY) == (
"https://shared.fastly.steamstatic.com/community_assets/images/items/999/real.jpg"
)
def test_background_animated_falls_back_to_poster(steam):
"""动画背景没有 CSS 图,取