"""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 = """
7
级
""" 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 = """
当前正在游戏
Subnautica 2:异星水域
""" 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 图,取