diff --git a/steam_backlog_enforcer/game_install.py b/steam_backlog_enforcer/game_install.py index 40762b2..c288dca 100644 --- a/steam_backlog_enforcer/game_install.py +++ b/steam_backlog_enforcer/game_install.py @@ -15,6 +15,29 @@ import time logger = logging.getLogger(__name__) +# Real Steam directory — used as a safety check to block destructive +# operations that leak through during testing. +_REAL_STEAMAPPS = Path("~/.local/share/Steam/steamapps").expanduser() + + +def _assert_not_real_steam(path: Path) -> None: + """Raise if *path* is inside the real Steam directory. + + Defence-in-depth guard: even if test fixtures fail to + redirect ``STEAMAPPS_PATH``, destructive operations + (uninstall, rmtree, unlink) will refuse to touch real files. + """ + try: + path.resolve().relative_to(_REAL_STEAMAPPS.resolve()) + except ValueError: + return # path is NOT under real Steam — safe to proceed + if STEAMAPPS_PATH.resolve() == _REAL_STEAMAPPS.resolve(): + msg = ( + f"SAFETY: refusing destructive operation on real Steam path " + f"{path!s} — STEAMAPPS_PATH was not redirected by test fixtures" + ) + raise RuntimeError(msg) + def _echo(msg: str = "", *, end: str = "\n", flush: bool = False) -> None: """Write user-facing CLI output to stdout. @@ -56,6 +79,7 @@ PROTECTED_APP_IDS = { 1007020, # Proton EasyAntiCheat Runtime # Games allowed to be installed anytime 3949040, # RV There Yet? + 2252570, } STEAMAPPS_PATH = Path("~/.local/share/Steam/steamapps").expanduser() @@ -312,6 +336,7 @@ def _remove_manifest(manifest: Path, game_name: str, app_id: int) -> bool: game_name: Human-readable game name for logging. app_id: Steam application ID. """ + _assert_not_real_steam(manifest) try: if manifest.exists(): manifest.unlink() @@ -333,6 +358,7 @@ def _remove_game_dirs(install_dir: Path | None, app_id: int) -> bool: """ success = True if install_dir and install_dir.is_dir(): + _assert_not_real_steam(install_dir) try: shutil.rmtree(install_dir) logger.info("Removed game files: %s", install_dir) @@ -343,6 +369,7 @@ def _remove_game_dirs(install_dir: Path | None, app_id: int) -> bool: for subdir in ("shadercache", "compatdata"): cache_path = STEAMAPPS_PATH / subdir / str(app_id) if cache_path.is_dir(): + _assert_not_real_steam(cache_path) with contextlib.suppress(OSError): shutil.rmtree(cache_path) logger.debug("Removed %s/%d", subdir, app_id) diff --git a/steam_backlog_enforcer/hltb.py b/steam_backlog_enforcer/hltb.py index e091a09..c90016b 100644 --- a/steam_backlog_enforcer/hltb.py +++ b/steam_backlog_enforcer/hltb.py @@ -154,6 +154,10 @@ def _pick_best_hltb_entry( When a short name like "FAITH" matches both "FAITH" (demo) and "FAITH: The Unholy Trinity" (full game), prefer the full game since Steam often lists the full game under the shorter name. + + When an exact match like "Timberman" (26 h) competes against an + unrelated subtitle entry like "Timberman: The Big Adventure" (2 h), + the exact match wins because it has more hours. """ if not candidates: return None @@ -165,36 +169,72 @@ def _pick_best_hltb_entry( return usable[0] lower = search_name.lower() + best_exact = _find_exact_match(usable, lower) + best_extended = _find_best_extended(usable, lower) + return _resolve_exact_vs_extended(best_exact, best_extended, usable) + + +def _find_exact_match( + usable: list[tuple[dict[str, Any], float]], + lower: str, +) -> tuple[dict[str, Any], float] | None: + """Find best exact name/alias match (highest comp_100).""" + return next( + ( + (e, s) + for e, s in sorted( + usable, + key=lambda x: x[0].get("comp_100", 0), + reverse=True, + ) + if (e.get("game_name") or "").lower() == lower + or (e.get("game_alias") or "").lower() == lower + ), + None, + ) + + +def _find_best_extended( + usable: list[tuple[dict[str, Any], float]], + lower: str, +) -> tuple[dict[str, Any], float] | None: + """Find best extended entry ("Name: Subtitle" / "Name - Subtitle"). + + Skips subset entries (prologue, demo, etc.). + """ + best: tuple[dict[str, Any], float] | None = None for entry, sim in usable: entry_name = (entry.get("game_name") or "").lower() if entry_name.startswith((lower + ":", lower + " -")): suffix = entry_name[len(lower) :].lstrip(" :-") - if not any(suffix.startswith(kw) for kw in _SUBSET_SUFFIXES): - # Only prefer this extended entry when it has strictly more - # comp_100 than any exact-name match. This prevents - # "Killing Floor: Toy Master" (1.2 h) from beating - # "Killing Floor" (296 h) while still letting - # "FAITH: The Unholy Trinity" (7 h) beat "FAITH" (0.5 h demo). - extended_hours = entry.get("comp_100", 0) - best_exact = next( - ( - (e, s) - for e, s in sorted( - usable, - key=lambda x: x[0].get("comp_100", 0), - reverse=True, - ) - if (e.get("game_name") or "").lower() == lower - or (e.get("game_alias") or "").lower() == lower - ), - None, - ) - if ( - best_exact is not None - and best_exact[0].get("comp_100", 0) >= extended_hours - ): - return best_exact - return entry, sim + if not any(suffix.startswith(kw) for kw in _SUBSET_SUFFIXES) and ( + best is None or entry.get("comp_100", 0) > best[0].get("comp_100", 0) + ): + best = (entry, sim) + return best + + +def _resolve_exact_vs_extended( + best_exact: tuple[dict[str, Any], float] | None, + best_extended: tuple[dict[str, Any], float] | None, + usable: list[tuple[dict[str, Any], float]], +) -> tuple[dict[str, Any], float]: + """Decide between exact match, extended entry, or highest similarity.""" + if best_exact is not None and best_extended is not None: + exact_hours = best_exact[0].get("comp_100", 0) + extended_hours = best_extended[0].get("comp_100", 0) + # Prefer the extended entry only when it has strictly more hours + # than the exact match. This lets "FAITH: The Unholy Trinity" + # (7 h) beat "FAITH" (0.5 h demo) while preventing + # "Timberman: The Big Adventure" (2 h) from beating + # "Timberman" (26 h). + if extended_hours > exact_hours: + return best_extended + return best_exact + if best_exact is not None: + return best_exact + if best_extended is not None: + return best_extended # Fall back to highest similarity. return max(usable, key=lambda x: x[1]) diff --git a/steam_backlog_enforcer/tests/conftest.py b/steam_backlog_enforcer/tests/conftest.py index 037348b..689ad37 100644 --- a/steam_backlog_enforcer/tests/conftest.py +++ b/steam_backlog_enforcer/tests/conftest.py @@ -7,12 +7,14 @@ to temporary directories. This stops tests from accidentally: user's current assignment) - Reading real appmanifest files from ~/.local/share/Steam/steamapps - Modifying /etc/hosts via the store blocker + - Corrupting the HLTB cache on disk + - Launching real Steam or calling real subprocess commands """ from __future__ import annotations from typing import TYPE_CHECKING -from unittest.mock import patch +from unittest.mock import MagicMock, patch import pytest @@ -57,6 +59,12 @@ def _isolate_filesystem(tmp_path: Path) -> Iterator[None]: "python_pkg.steam_backlog_enforcer.game_install.STEAMAPPS_PATH", fake_steamapps, ), + # HLTB cache file (computed at import time from CONFIG_DIR, so + # patching CONFIG_DIR alone does not redirect it) + patch( + "python_pkg.steam_backlog_enforcer._hltb_types.HLTB_CACHE_FILE", + fake_config / "hltb_cache.json", + ), # /etc/hosts (store blocker) patch( "python_pkg.steam_backlog_enforcer.store_blocker.HOSTS_FILE", @@ -68,3 +76,43 @@ def _isolate_filesystem(tmp_path: Path) -> Iterator[None]: ), ): yield + + +@pytest.fixture(autouse=True) +def _block_real_subprocesses() -> Iterator[None]: + """Block subprocess calls that could launch real Steam or modify system. + + Individual tests that need to test subprocess behaviour should + patch the specific module's ``subprocess.run`` / ``subprocess.Popen`` + themselves — their local patch will override this one. + """ + noop_run = MagicMock(return_value=MagicMock(returncode=1)) + noop_popen = MagicMock() + + with ( + patch( + "python_pkg.steam_backlog_enforcer.game_install.subprocess.run", + noop_run, + ), + patch( + "python_pkg.steam_backlog_enforcer.game_install.subprocess.Popen", + noop_popen, + ), + patch( + "python_pkg.steam_backlog_enforcer.enforcer.subprocess.run", + noop_run, + ), + patch( + "python_pkg.steam_backlog_enforcer.store_blocker.subprocess.run", + noop_run, + ), + patch( + "python_pkg.steam_backlog_enforcer.library_hider.subprocess.run", + noop_run, + ), + patch( + "python_pkg.steam_backlog_enforcer.library_hider.subprocess.Popen", + noop_popen, + ), + ): + yield diff --git a/steam_backlog_enforcer/tests/test_game_install.py b/steam_backlog_enforcer/tests/test_game_install.py index 6dcfe66..6b558cf 100644 --- a/steam_backlog_enforcer/tests/test_game_install.py +++ b/steam_backlog_enforcer/tests/test_game_install.py @@ -6,7 +6,10 @@ import os from typing import TYPE_CHECKING from unittest.mock import MagicMock, patch +import pytest + from python_pkg.steam_backlog_enforcer.game_install import ( + _assert_not_real_steam, _echo, _ensure_steam_running, _get_real_user, @@ -22,7 +25,43 @@ from python_pkg.steam_backlog_enforcer.game_install import ( if TYPE_CHECKING: from pathlib import Path - import pytest + +PKG = "python_pkg.steam_backlog_enforcer.game_install" + + +class TestAssertNotRealSteam: + """Tests for the _assert_not_real_steam safety guard.""" + + def test_allows_tmp_path(self, tmp_path: Path) -> None: + """Non-Steam paths pass through without raising.""" + _assert_not_real_steam(tmp_path / "appmanifest_440.acf") + + def test_raises_when_real_steam_not_redirected(self, tmp_path: Path) -> None: + """Raises when path is under real Steam and STEAMAPPS_PATH is real.""" + real = tmp_path / "real_steam" + real.mkdir() + fake_manifest = real / "appmanifest_440.acf" + fake_manifest.touch() + with ( + patch(f"{PKG}._REAL_STEAMAPPS", real), + patch(f"{PKG}.STEAMAPPS_PATH", real), + pytest.raises(RuntimeError, match="SAFETY"), + ): + _assert_not_real_steam(fake_manifest) + + def test_allows_when_steamapps_redirected(self, tmp_path: Path) -> None: + """No raise when STEAMAPPS_PATH differs from _REAL_STEAMAPPS.""" + real = tmp_path / "real_steam" + real.mkdir() + fake_manifest = real / "appmanifest_440.acf" + fake_manifest.touch() + redirected = tmp_path / "fake_steam" + redirected.mkdir() + with ( + patch(f"{PKG}._REAL_STEAMAPPS", real), + patch(f"{PKG}.STEAMAPPS_PATH", redirected), + ): + _assert_not_real_steam(fake_manifest) class TestEcho: diff --git a/steam_backlog_enforcer/tests/test_hltb.py b/steam_backlog_enforcer/tests/test_hltb.py index 7ca1d02..959ca6f 100644 --- a/steam_backlog_enforcer/tests/test_hltb.py +++ b/steam_backlog_enforcer/tests/test_hltb.py @@ -345,3 +345,89 @@ class TestPickBestHltbEntry: ) assert result is not None assert result[0]["game_name"] == "NEEDY GIRL OVERDOSE" + + def test_exact_match_beats_different_subtitled_game(self) -> None: + """Exact 'Timberman' (26.5 h) must beat 'Timberman: The Big Adventure' (2 h). + + Unlike FAITH where the short name is a demo, here the short name + is the real full game and the subtitled entry is a different, shorter + game. The exact match should win because it has more hours. + """ + base: dict[str, Any] = { + "game_name": "Timberman", + "comp_100": 95400, # 26.5 h + } + other: dict[str, Any] = { + "game_name": "Timberman: The Big Adventure", + "comp_100": 7200, # 2 h + } + timberman_vs: dict[str, Any] = { + "game_name": "Timberman VS", + "comp_100": 23400, # 6.5 h + } + result = _pick_best_hltb_entry( + "Timberman", + [(other, 0.49), (timberman_vs, 0.86), (base, 1.0)], + ) + assert result is not None + assert result[0]["game_name"] == "Timberman" + + def test_exact_match_wins_even_when_extended_appears_first(self) -> None: + """Exact match wins regardless of candidate ordering.""" + base: dict[str, Any] = { + "game_name": "Timberman", + "comp_100": 95400, # 26.5 h + } + other: dict[str, Any] = { + "game_name": "Timberman: The Big Adventure", + "comp_100": 7200, # 2 h + } + # Extended entry appears first in the list. + result = _pick_best_hltb_entry( + "Timberman", + [(other, 0.49), (base, 1.0)], + ) + assert result is not None + assert result[0]["game_name"] == "Timberman" + + def test_exact_only_no_extended(self) -> None: + """Exact match returned when no extended entries exist at all.""" + exact: dict[str, Any] = { + "game_name": "Celeste", + "comp_100": 180000, # 50 h + } + unrelated: dict[str, Any] = { + "game_name": "Unrelated Game", + "comp_100": 7200, + } + result = _pick_best_hltb_entry( + "Celeste", + [(exact, 1.0), (unrelated, 0.6)], + ) + assert result is not None + assert result[0]["game_name"] == "Celeste" + + def test_no_exact_no_extended_falls_back(self) -> None: + """When no exact or extended match exists, fall to highest similarity.""" + a: dict[str, Any] = {"game_name": "FooBar", "comp_100": 3600} + b: dict[str, Any] = {"game_name": "FooBaz", "comp_100": 7200} + result = _pick_best_hltb_entry("Foo", [(a, 0.7), (b, 0.8)]) + assert result is not None + assert result[0]["game_name"] == "FooBaz" + + def test_extended_only_no_exact(self) -> None: + """Extended entry returned when no exact name match exists.""" + extended: dict[str, Any] = { + "game_name": "Neon: Ultimate Edition", + "comp_100": 36000, + } + unrelated: dict[str, Any] = { + "game_name": "Neon Lights", + "comp_100": 3600, + } + result = _pick_best_hltb_entry( + "Neon", + [(extended, 0.6), (unrelated, 0.7)], + ) + assert result is not None + assert result[0]["game_name"] == "Neon: Ultimate Edition" diff --git a/steam_backlog_enforcer/tests/test_library_hider.py b/steam_backlog_enforcer/tests/test_library_hider.py index f85779a..1d9f074 100644 --- a/steam_backlog_enforcer/tests/test_library_hider.py +++ b/steam_backlog_enforcer/tests/test_library_hider.py @@ -20,8 +20,6 @@ from python_pkg.steam_backlog_enforcer.library_hider import ( _wait_for_cdp_ready, _wait_for_collections_ready, ensure_steam_debug_port, - hide_other_games, - unhide_all_games, ) @@ -425,85 +423,3 @@ class TestEnsureSteamDebugPort: ), ): ensure_steam_debug_port() - - -class TestHideOtherGames: - """Tests for hide_other_games.""" - - def test_hides(self) -> None: - with ( - patch( - "python_pkg.steam_backlog_enforcer.library_hider.ensure_steam_debug_port", - ), - patch( - "python_pkg.steam_backlog_enforcer.library_hider._evaluate_js", - return_value={ - "result": {"result": {"value": '{"totalHidden": 5}'}}, - }, - ), - patch( - "python_pkg.steam_backlog_enforcer.library_hider._cdp_result_value", - return_value='{"totalHidden": 5}', - ), - ): - count = hide_other_games([1, 2, 3], 1) - assert count == 5 - - def test_empty_list(self) -> None: - with ( - patch( - "python_pkg.steam_backlog_enforcer.library_hider.ensure_steam_debug_port", - ), - patch( - "python_pkg.steam_backlog_enforcer.library_hider._evaluate_js", - return_value={ - "result": {"result": {"value": '{"totalHidden": 0}'}}, - }, - ), - patch( - "python_pkg.steam_backlog_enforcer.library_hider._cdp_result_value", - return_value='{"totalHidden": 0}', - ), - ): - count = hide_other_games([1], 1) - assert count == 0 - - def test_no_allowed(self) -> None: - with ( - patch( - "python_pkg.steam_backlog_enforcer.library_hider.ensure_steam_debug_port", - ), - patch( - "python_pkg.steam_backlog_enforcer.library_hider._evaluate_js", - return_value={ - "result": {"result": {"value": '{"totalHidden": 2}'}}, - }, - ), - patch( - "python_pkg.steam_backlog_enforcer.library_hider._cdp_result_value", - return_value='{"totalHidden": 2}', - ), - ): - count = hide_other_games([1, 2], None) - assert count == 2 - - -class TestUnhideAllGames: - """Tests for unhide_all_games.""" - - def test_unhides(self) -> None: - with ( - patch( - "python_pkg.steam_backlog_enforcer.library_hider.ensure_steam_debug_port", - ), - patch( - "python_pkg.steam_backlog_enforcer.library_hider._evaluate_js", - return_value={"result": {"result": {"value": '{"count": 10}'}}}, - ), - patch( - "python_pkg.steam_backlog_enforcer.library_hider._cdp_result_value", - return_value='{"count": 10}', - ), - ): - count = unhide_all_games([1, 2, 3]) - assert count == 10 diff --git a/steam_backlog_enforcer/tests/test_library_hider_part2.py b/steam_backlog_enforcer/tests/test_library_hider_part2.py index 7078779..affa1d4 100644 --- a/steam_backlog_enforcer/tests/test_library_hider_part2.py +++ b/steam_backlog_enforcer/tests/test_library_hider_part2.py @@ -8,7 +8,9 @@ from unittest.mock import MagicMock, patch from python_pkg.steam_backlog_enforcer.library_hider import ( _run_as_user, + hide_other_games, restart_steam, + unhide_all_games, ) PKG = "python_pkg.steam_backlog_enforcer.library_hider" @@ -116,3 +118,77 @@ class TestRestartSteam: patch(f"{PKG}._wait_for_cdp_ready", return_value=False), ): restart_steam() + + +class TestHideOtherGames: + """Tests for hide_other_games.""" + + def test_hides(self) -> None: + with ( + patch(f"{PKG}.ensure_steam_debug_port"), + patch( + f"{PKG}._evaluate_js", + return_value={ + "result": {"result": {"value": '{"totalHidden": 5}'}}, + }, + ), + patch( + f"{PKG}._cdp_result_value", + return_value='{"totalHidden": 5}', + ), + ): + count = hide_other_games([1, 2, 3], 1) + assert count == 5 + + def test_empty_list(self) -> None: + with ( + patch(f"{PKG}.ensure_steam_debug_port"), + patch( + f"{PKG}._evaluate_js", + return_value={ + "result": {"result": {"value": '{"totalHidden": 0}'}}, + }, + ), + patch( + f"{PKG}._cdp_result_value", + return_value='{"totalHidden": 0}', + ), + ): + count = hide_other_games([1], 1) + assert count == 0 + + def test_no_allowed(self) -> None: + with ( + patch(f"{PKG}.ensure_steam_debug_port"), + patch( + f"{PKG}._evaluate_js", + return_value={ + "result": {"result": {"value": '{"totalHidden": 2}'}}, + }, + ), + patch( + f"{PKG}._cdp_result_value", + return_value='{"totalHidden": 2}', + ), + ): + count = hide_other_games([1, 2], None) + assert count == 2 + + +class TestUnhideAllGames: + """Tests for unhide_all_games.""" + + def test_unhides(self) -> None: + with ( + patch(f"{PKG}.ensure_steam_debug_port"), + patch( + f"{PKG}._evaluate_js", + return_value={"result": {"result": {"value": '{"count": 10}'}}}, + ), + patch( + f"{PKG}._cdp_result_value", + return_value='{"count": 10}', + ), + ): + count = unhide_all_games([1, 2, 3]) + assert count == 10