Files
HeXi/tests/test_steam_profile_parse.py
T

390 lines
14 KiB
Python
Raw Normal View History

"""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 = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?><profile>
<steamID64>76561199135038179</steamID64>
<steamID><![CDATA[zhaomaoniu]]></steamID>
<onlineState>online</onlineState>
<stateMessage><![CDATA[Online]]></stateMessage>
<privacyState>public</privacyState>
<vacBanned>0</vacBanned>
<tradeBanState>None</tradeBanState>
<isLimitedAccount>0</isLimitedAccount>
<memberSince>January 29, 2021</memberSince>
<hoursPlayed2Wk>0.0</hoursPlayed2Wk>
</profile>"""
PROFILE_XML_IN_GAME = """<profile>
<onlineState>in-game</onlineState>
<stateMessage><![CDATA[In-Game: Team Fortress 2]]></stateMessage>
<privacyState>public</privacyState>
<memberSince>January 29, 2021</memberSince>
</profile>"""
PROFILE_XML_PRIVATE = """<profile>
<steamID64>76561199135038179</steamID64>
<privacyState>private</privacyState>
</profile>"""
PROFILE_XML_BANNED = """<profile>
<onlineState>offline</onlineState>
<privacyState>public</privacyState>
<vacBanned>1</vacBanned>
<tradeBanState>Trade Banned</tradeBanState>
<isLimitedAccount>1</isLimitedAccount>
<hoursPlayed2Wk>7.5</hoursPlayed2Wk>
</profile>"""
PROFILE_HTML = """
<div class="profile_header_badgeinfo">
<div class="persona_name persona_level">
<div class="friendPlayerLevel lvl_0"><span class="friendPlayerLevelNum">
7
</span></div> 级
</div>
</div>
<div class="profile_item_links">
<div class="profile_count_link ellipsis">
<a href="https://steamcommunity.com/profiles/76561199135038179/games/">
<span class="count_link_label">游戏</span>&nbsp;
<span class="profile_count_link_total"> 1,234 </span>
</a>
</div>
<div class="profile_count_link ellipsis">
<a href="https://steamcommunity.com/profiles/76561199135038179/inventory/">
<span class="count_link_label">库存</span>&nbsp;
<span class="profile_count_link_total"> &nbsp; </span>
</a>
</div>
<div class="profile_count_link ellipsis">
<a href="https://steamcommunity.com/profiles/76561199135038179/recommended/">
<span class="count_link_label">评测</span>&nbsp;
<span class="profile_count_link_total"> 2 </span>
</a>
</div>
<div class="profile_count_link ellipsis">
<a href="https://steamcommunity.com/profiles/76561199135038179/badges/">
<span class="count_link_label">徽章</span>&nbsp;
<span class="profile_count_link_total"> 7 </span>
</a>
</div>
<div class="profile_count_link ellipsis">
<a href="https://steamcommunity.com/profiles/76561199135038179/friends/">
<span class="count_link_label">好友</span>&nbsp;
<span class="profile_count_link_total"> 12 </span>
</a>
</div>
</div>
"""
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 = "<profile><memberSince>2021年1月29日</memberSince></profile>"
assert steam.parse_profile_xml(xml_zh)["member_since"] == "2021-01-29"
xml_odd = "<profile><memberSince>29/01/2021</memberSince></profile>"
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("<html><body>无资料</body></html>")
assert data["level"] is None
assert data["counts"] == {}
IN_GAME_HTML = """
<div class="responsive_status_info">
<div class="profile_in_game persona in-game">
<div class="profile_in_game_header">当前正在游戏</div>
<div class="profile_in_game_name"> Subnautica 2:异星水域 </div>
<div class="profile_in_game_joingame"><a href="steam://joinlobby/1962700/xyz/">加入游戏</a></div>
</div>
</div>
"""
def test_build_profile_status_in_game(steam):
# XML 两种写法都要认:"In-Game: X" 和 "In-Game<br/>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<br/>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 = """
<style>
.profile_header_bg { background-image: url( 'https://shared.fastly.steamstatic.com/community_assets/images/items/2458530/abc.jpg' ); }
</style>
"""
BACKDROP_CSS_TIGHT = """
<style>
body { background-image: url("https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/items/123/def.png"); }
</style>
"""
BACKDROP_CSS_WITH_DECOY = """
<style>
.btn { background-image: url('https://example.com/logo.png'); }
.bg { background-image: url('https://shared.fastly.steamstatic.com/community_assets/images/items/999/real.jpg'); }
</style>
"""
BACKDROP_ANIMATED = """
<div class="profile_animated_background">
<video playsinline autoplay muted loop poster="https://shared.fastly.steamstatic.com/community_assets/images/items/1173010/aaa.jpg">
<source src="https://shared.fastly.steamstatic.com/community_assets/images/items/1173010/bbb.webm" type="video/webm">
<source src="https://shared.fastly.steamstatic.com/community_assets/images/items/1173010/ccc.mp4" type="video/mp4">
</video>
</div>
"""
AVATAR_FRAME = """
<div class="playerAvatar profile_header_size online">
<div class="playerAvatarAutoSizeInner">
<div class="profile_avatar_frame">
<picture>
<source media="(prefers-reduced-motion: reduce)" srcset="https://shared.fastly.steamstatic.com/community_assets/images/items/1263950/static.png"></source>
<source srcset="https://shared.fastly.steamstatic.com/community_assets/images/items/1263950/animated.png"></source>
<img src="https://shared.fastly.steamstatic.com/community_assets/images/items/1263950/animated.png">
</picture>
</div>
<picture>
<img srcset="https://avatars.fastly.steamstatic.com/aaa_full.jpg">
</picture>
</div>
</div>
"""
AVATAR_FRAME_IMG_ONLY = """
<div class="playerAvatar">
<div class="profile_avatar_frame">
<img src="https://shared.fastly.steamstatic.com/community_assets/images/items/1/only.png">
</div>
</div>
"""
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 图,取 <video poster> 的静态首帧"""
assert steam.parse_profile_background_url(BACKDROP_ANIMATED) == (
"https://shared.fastly.steamstatic.com/community_assets/images/items/1173010/aaa.jpg"
)
assert steam.parse_profile_background_url("<html><body>无背景</body></html>") is None
def test_avatar_frame_prefers_static_source(steam):
assert steam.parse_avatar_frame_url(AVATAR_FRAME) == (
"https://shared.fastly.steamstatic.com/community_assets/images/items/1263950/static.png"
)
def test_avatar_frame_img_fallback(steam):
assert steam.parse_avatar_frame_url(AVATAR_FRAME_IMG_ONLY) == (
"https://shared.fastly.steamstatic.com/community_assets/images/items/1/only.png"
)
# 没有头像框(普通头像的 <picture>)不该误判
assert steam.parse_avatar_frame_url(PROFILE_HTML) is None
def test_describe_ban_priority(steam):
assert steam.describe_ban(True, "Trade Banned", True) == {
"text": "VAC 封禁",
"kind": "vac",
}
assert steam.describe_ban(False, "Trade Banned", False) == {
"text": "交易封禁",
"kind": "trade",
}
assert steam.describe_ban(False, "Probation", False) == {
"text": "交易限制",
"kind": "trade",
}
assert steam.describe_ban(False, None, True) == {
"text": "受限账号",
"kind": "limited",
}
assert steam.describe_ban(False, None, False) is None