390 lines
14 KiB
Python
390 lines
14 KiB
Python
"""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>
|
|||
|
|
<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>
|
|||
|
|
<span class="profile_count_link_total"> </span>
|
|||
|
|
</a>
|
|||
|
|
</div>
|
|||
|
|
<div class="profile_count_link ellipsis">
|
|||
|
|
<a href="https://steamcommunity.com/profiles/76561199135038179/recommended/">
|
|||
|
|
<span class="count_link_label">评测</span>
|
|||
|
|
<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>
|
|||
|
|
<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>
|
|||
|
|
<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
|