feat: Steam game & library detection (M6) — 0.8.0
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>
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user