0642eb4712
The first slice of M6 (gaming-environment checks): detect a user's Steam libraries and the games installed in each — also the D12 "pick a game" foundation. - core/steam.py: multi-install/library discovery (libraryfolders.vdf, symlink dedupe, native/Flatpak/Snap), appmanifest_*.acf scan with runtime/Proton/ redist filtering, scan cache + new-game diff. Stdlib only. VDF keys read case-insensitively (e.g. lastupdated vs SizeOnDisk). - Libraries are opt-in (config steam_libraries); the flat TOML writer now emits list/array values. - GUI Games page: library checkboxes with per-library counts, game list, background rescan on every launch, NEW badge + sidebar count for games installed since the last scan (acknowledged when viewed). - CLI: rigdoctor games / games libraries [--enable|--disable|--all|--json] (headless-complete, D17). - Tests for VDF parse, scan, tool filter, cache diff, config list round-trip. - Docs (MODULES/ROADMAP) updated; version 0.7.3 -> 0.8.0. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
148 lines
5.8 KiB
Python
148 lines
5.8 KiB
Python
"""Tests for M6 Steam library & game detection (VDF parse, scan, tool filter, cache diff)."""
|
|
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
from unittest import mock
|
|
|
|
from rigdoctor.core import steam
|
|
|
|
_GAME_ACF = """"AppState"
|
|
{{
|
|
\t"appid"\t\t"{appid}"
|
|
\t"name"\t\t"{name}"
|
|
\t"installdir"\t\t"{installdir}"
|
|
\t"SizeOnDisk"\t\t"{size}"
|
|
\t"LastUpdated"\t\t"{updated}"
|
|
}}
|
|
"""
|
|
|
|
_LIBRARYFOLDERS = """"libraryfolders"
|
|
{{
|
|
\t"0"
|
|
\t{{
|
|
\t\t"path"\t\t"{path}"
|
|
\t\t"label"\t\t"Main"
|
|
\t\t"apps"
|
|
\t\t{{
|
|
\t\t\t"570"\t\t"123"
|
|
\t\t}}
|
|
\t}}
|
|
}}
|
|
"""
|
|
|
|
|
|
def _make_library(root: Path, games) -> Path:
|
|
"""games: list of (appid, name, installdir, size, updated). Returns the library path."""
|
|
steamapps = root / "steamapps"
|
|
steamapps.mkdir(parents=True, exist_ok=True)
|
|
for appid, name, installdir, size, updated in games:
|
|
(steamapps / f"appmanifest_{appid}.acf").write_text(
|
|
_GAME_ACF.format(appid=appid, name=name, installdir=installdir, size=size, updated=updated)
|
|
)
|
|
return root
|
|
|
|
|
|
class VdfTests(unittest.TestCase):
|
|
def test_parse_nested_and_pairs(self):
|
|
data = steam._parse_vdf(_GAME_ACF.format(
|
|
appid="570", name="Dota 2", installdir="dota 2 beta", size="15", updated="1700"))
|
|
state = data["AppState"]
|
|
self.assertEqual(state["appid"], "570")
|
|
self.assertEqual(state["name"], "Dota 2")
|
|
self.assertEqual(state["installdir"], "dota 2 beta")
|
|
|
|
def test_parse_handles_quotes_in_names(self):
|
|
acf = _GAME_ACF.format(appid="1", name="Baldur\\'s Gate 3", installdir="bg3", size="1", updated="1")
|
|
data = steam._parse_vdf(acf)
|
|
self.assertIn("Baldur", data["AppState"]["name"])
|
|
|
|
def test_parse_garbage_returns_empty(self):
|
|
self.assertEqual(steam._parse_vdf("not vdf at all"), {})
|
|
|
|
|
|
class ToolFilterTests(unittest.TestCase):
|
|
def test_known_tool_appid(self):
|
|
self.assertTrue(steam.is_tool("228980", "Steamworks Common Redistributables"))
|
|
|
|
def test_proton_name_prefix(self):
|
|
self.assertTrue(steam.is_tool("9999999", "Proton 8.0"))
|
|
self.assertTrue(steam.is_tool("9999998", "Steam Linux Runtime 3.0 (sniper)"))
|
|
|
|
def test_real_game_is_not_a_tool(self):
|
|
self.assertFalse(steam.is_tool("570", "Dota 2"))
|
|
|
|
|
|
class ScanTests(unittest.TestCase):
|
|
def test_scan_library_filters_tools(self):
|
|
with tempfile.TemporaryDirectory() as d:
|
|
lib = _make_library(Path(d), [
|
|
("570", "Dota 2", "dota 2 beta", "15000000000", "1700000000"),
|
|
("228980", "Steamworks Common Redistributables", "Steamworks Shared", "0", "0"),
|
|
("1493710", "Proton Experimental", "Proton - Experimental", "0", "0"),
|
|
])
|
|
games = steam.scan_library(str(lib))
|
|
names = {g.name for g in games}
|
|
self.assertEqual(names, {"Dota 2"})
|
|
self.assertEqual(games[0].size_bytes, 15000000000)
|
|
|
|
def test_scan_games_dedupes_and_sorts(self):
|
|
with tempfile.TemporaryDirectory() as d1, tempfile.TemporaryDirectory() as d2:
|
|
a = _make_library(Path(d1), [("10", "Zeta", "zeta", "1", "1"), ("20", "Alpha", "alpha", "1", "1")])
|
|
b = _make_library(Path(d2), [("20", "Alpha", "alpha", "1", "1")]) # dup appid 20
|
|
games = steam.scan_games([str(a), str(b)])
|
|
self.assertEqual([g.name for g in games], ["Alpha", "Zeta"]) # sorted, deduped
|
|
|
|
|
|
class DiscoverTests(unittest.TestCase):
|
|
def test_discover_reads_libraryfolders(self):
|
|
with tempfile.TemporaryDirectory() as d:
|
|
root = Path(d) / "Steam"
|
|
(root / "steamapps").mkdir(parents=True)
|
|
extra = Path(d) / "Extra"
|
|
(extra / "steamapps").mkdir(parents=True)
|
|
(root / "steamapps" / "libraryfolders.vdf").write_text(
|
|
_LIBRARYFOLDERS.format(path=str(extra)))
|
|
with mock.patch.object(steam, "steam_roots", return_value=[root]):
|
|
libs = steam.discover_libraries()
|
|
paths = {lib.path for lib in libs}
|
|
self.assertIn(str(root.resolve()), paths) # root itself
|
|
self.assertIn(str(extra.resolve()), paths) # the configured extra library
|
|
|
|
|
|
class CacheDiffTests(unittest.TestCase):
|
|
def _rescan(self, lib, games_file, cfg):
|
|
with mock.patch.object(steam, "GAMES_FILE", games_file):
|
|
return steam.rescan(cfg=cfg)
|
|
|
|
def test_first_scan_has_no_new_then_added_game_is_new(self):
|
|
with tempfile.TemporaryDirectory() as d:
|
|
lib = _make_library(Path(d) / "lib", [("10", "Alpha", "alpha", "1", "1")])
|
|
games_file = Path(d) / "games.json"
|
|
cfg = {"steam_libraries": [str(lib)]}
|
|
|
|
first = self._rescan(lib, games_file, cfg)
|
|
self.assertEqual(first.new_appids, []) # first run flags nothing as new
|
|
|
|
# Install a second game; it should be flagged new on the next scan.
|
|
_make_library(lib, [("10", "Alpha", "alpha", "1", "1"), ("20", "Beta", "beta", "1", "1")])
|
|
second = self._rescan(lib, games_file, cfg)
|
|
self.assertEqual(second.new_appids, ["20"])
|
|
self.assertEqual({g.name for g in second.games}, {"Alpha", "Beta"})
|
|
|
|
def test_acknowledge_clears_new(self):
|
|
with tempfile.TemporaryDirectory() as d:
|
|
lib = _make_library(Path(d) / "lib", [("10", "Alpha", "alpha", "1", "1")])
|
|
games_file = Path(d) / "games.json"
|
|
cfg = {"steam_libraries": [str(lib)]}
|
|
self._rescan(lib, games_file, cfg)
|
|
_make_library(lib, [("10", "Alpha", "alpha", "1", "1"), ("20", "Beta", "beta", "1", "1")])
|
|
self._rescan(lib, games_file, cfg)
|
|
with mock.patch.object(steam, "GAMES_FILE", games_file):
|
|
steam.acknowledge_new()
|
|
self.assertEqual(steam.load_cache()["new_appids"], [])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|