test: achieve 100% branch coverage across all python_pkg packages

- Add comprehensive tests for all packages (3572 tests, 100% branch coverage)
- Split oversized test files to stay under 500-line limit
- Add per-file ruff ignores for test-appropriate suppressions
- Fix _cache_decks.py to properly convert JSON lists to tuples
- Add session-scoped conftest fixture for logging handler cleanup (Python 3.14)
- Update ruff pre-commit hook to v0.15.2
- Add codespell ignore words for test data
- Add generated output files to .gitignore
This commit is contained in:
Krzysztof Rudnicki 2026-03-21 17:51:36 +01:00
parent de4134fcf4
commit 1f02540eb5
21 changed files with 5312 additions and 5 deletions

View File

@ -117,7 +117,7 @@ async def _get_auth_token(
ts = int(time.time() * 1000)
headers = {
"User-Agent": (
"Mozilla/5.0 (X11; Linux x86_64; rv:136.0)" " Gecko/20100101 Firefox/136.0"
"Mozilla/5.0 (X11; Linux x86_64; rv:136.0) Gecko/20100101 Firefox/136.0"
),
"referer": "https://howlongtobeat.com/",
}
@ -313,7 +313,7 @@ async def _fetch_batch(
"content-type": "application/json",
"accept": "*/*",
"User-Agent": (
"Mozilla/5.0 (X11; Linux x86_64; rv:136.0)" " Gecko/20100101 Firefox/136.0"
"Mozilla/5.0 (X11; Linux x86_64; rv:136.0) Gecko/20100101 Firefox/136.0"
),
"referer": "https://howlongtobeat.com/",
"x-auth-token": token,

View File

@ -44,8 +44,6 @@ def _get_shared_js_ws_url() -> str | None:
"""Query the CDP HTTP endpoint and return the SharedJSContext WS URL."""
url = f"http://127.0.0.1:{_CDP_PORT}/json"
try:
if not url.startswith(("http://", "https://")):
return None
with urllib.request.urlopen(url, timeout=5) as resp:
targets = json.loads(resp.read())
except (OSError, ValueError):
@ -247,7 +245,7 @@ def hide_other_games(
collectionStore.SetAppsAsHidden(newIds, true);
}}
// Unhide the allowed game if it was hidden.
const allowedId = {allowed_app_id if allowed_app_id is not None else 'null'};
const allowedId = {allowed_app_id if allowed_app_id is not None else "null"};
if (allowedId !== null && collectionStore.BIsHidden(allowedId)) {{
collectionStore.SetAppsAsHidden([allowedId], false);
}}

View File

View File

@ -0,0 +1,177 @@
"""Tests for config module."""
from __future__ import annotations
import json
from typing import TYPE_CHECKING, Any
from unittest.mock import patch
import pytest
from python_pkg.steam_backlog_enforcer.config import (
Config,
State,
interactive_setup,
load_snapshot,
save_snapshot,
)
if TYPE_CHECKING:
from pathlib import Path
class TestConfig:
"""Tests for Config dataclass."""
def test_defaults(self) -> None:
cfg = Config()
assert cfg.steam_api_key == ""
assert cfg.steam_id == ""
assert cfg.skip_app_ids == []
assert cfg.block_store is True
assert cfg.kill_unauthorized_games is True
assert cfg.uninstall_other_games is True
assert cfg.desktop_notifications is True
def test_save(self, tmp_path: Path) -> None:
cfg = Config(steam_api_key="abc", steam_id="123")
config_dir = tmp_path / "cfg"
config_file = config_dir / "config.json"
with (
patch("python_pkg.steam_backlog_enforcer.config.CONFIG_DIR", config_dir),
patch("python_pkg.steam_backlog_enforcer.config.CONFIG_FILE", config_file),
):
cfg.save()
data = json.loads(config_file.read_text(encoding="utf-8"))
assert data["steam_api_key"] == "abc"
assert data["steam_id"] == "123"
def test_load_existing(self, tmp_path: Path) -> None:
config_file = tmp_path / "config.json"
config_file.write_text(
json.dumps({"steam_api_key": "key1", "steam_id": "id1"}) + "\n",
encoding="utf-8",
)
with patch("python_pkg.steam_backlog_enforcer.config.CONFIG_FILE", config_file):
cfg = Config.load()
assert cfg.steam_api_key == "key1"
assert cfg.steam_id == "id1"
def test_load_missing(self, tmp_path: Path) -> None:
config_file = tmp_path / "nonexistent.json"
with patch("python_pkg.steam_backlog_enforcer.config.CONFIG_FILE", config_file):
cfg = Config.load()
assert cfg.steam_api_key == ""
def test_load_extra_fields_ignored(self, tmp_path: Path) -> None:
config_file = tmp_path / "config.json"
config_file.write_text(
json.dumps({"steam_api_key": "k", "unknown_field": 42}) + "\n",
encoding="utf-8",
)
with patch("python_pkg.steam_backlog_enforcer.config.CONFIG_FILE", config_file):
cfg = Config.load()
assert cfg.steam_api_key == "k"
class TestState:
"""Tests for State dataclass."""
def test_defaults(self) -> None:
state = State()
assert state.current_app_id is None
assert state.current_game_name == ""
assert state.finished_app_ids == []
def test_save(self, tmp_path: Path) -> None:
state = State(current_app_id=100, current_game_name="TestGame")
config_dir = tmp_path / "cfg"
state_file = config_dir / "state.json"
with (
patch("python_pkg.steam_backlog_enforcer.config.CONFIG_DIR", config_dir),
patch("python_pkg.steam_backlog_enforcer.config.STATE_FILE", state_file),
):
state.save()
data = json.loads(state_file.read_text(encoding="utf-8"))
assert data["current_app_id"] == 100
assert data["current_game_name"] == "TestGame"
def test_load_existing(self, tmp_path: Path) -> None:
state_file = tmp_path / "state.json"
state_file.write_text(
json.dumps(
{
"current_app_id": 50,
"current_game_name": "G",
"finished_app_ids": [1, 2],
}
)
+ "\n",
encoding="utf-8",
)
with patch("python_pkg.steam_backlog_enforcer.config.STATE_FILE", state_file):
st = State.load()
assert st.current_app_id == 50
assert st.finished_app_ids == [1, 2]
def test_load_missing(self, tmp_path: Path) -> None:
state_file = tmp_path / "nonexistent.json"
with patch("python_pkg.steam_backlog_enforcer.config.STATE_FILE", state_file):
st = State.load()
assert st.current_app_id is None
class TestSnapshot:
"""Tests for snapshot save/load."""
def test_save_and_load(self, tmp_path: Path) -> None:
config_dir = tmp_path / "cfg"
snap_file = config_dir / "snapshot.json"
with (
patch("python_pkg.steam_backlog_enforcer.config.CONFIG_DIR", config_dir),
patch("python_pkg.steam_backlog_enforcer.config.SNAPSHOT_FILE", snap_file),
):
data: list[dict[str, Any]] = [{"app_id": 1, "name": "G1"}]
save_snapshot(data)
loaded = load_snapshot()
assert loaded == data
def test_load_none(self, tmp_path: Path) -> None:
snap_file = tmp_path / "nonexistent.json"
with patch("python_pkg.steam_backlog_enforcer.config.SNAPSHOT_FILE", snap_file):
assert load_snapshot() is None
class TestInteractiveSetup:
"""Tests for interactive_setup."""
def test_success(self, tmp_path: Path) -> None:
config_dir = tmp_path / "cfg"
config_file = config_dir / "config.json"
with (
patch("python_pkg.steam_backlog_enforcer.config.CONFIG_DIR", config_dir),
patch("python_pkg.steam_backlog_enforcer.config.CONFIG_FILE", config_file),
patch("builtins.input", side_effect=["mykey", "myid"]),
):
cfg = interactive_setup()
assert cfg.steam_api_key == "mykey"
assert cfg.steam_id == "myid"
assert config_file.exists()
def test_empty_api_key_exits(self) -> None:
with (
patch("builtins.input", return_value=""),
pytest.raises(SystemExit),
):
interactive_setup()
def test_empty_steam_id_exits(self, tmp_path: Path) -> None:
config_dir = tmp_path / "cfg"
config_file = config_dir / "config.json"
with (
patch("python_pkg.steam_backlog_enforcer.config.CONFIG_DIR", config_dir),
patch("python_pkg.steam_backlog_enforcer.config.CONFIG_FILE", config_file),
patch("builtins.input", side_effect=["key", ""]),
pytest.raises(SystemExit),
):
interactive_setup()

View File

@ -0,0 +1,352 @@
"""Tests for _enforce_loop module."""
from __future__ import annotations
from unittest.mock import MagicMock, patch
from python_pkg.steam_backlog_enforcer._enforce_loop import (
_enforce_auto_install,
_enforce_hide_games,
_enforce_loop_iteration,
_enforce_setup,
_guard_installed_games,
do_enforce,
get_all_owned_app_ids,
)
from python_pkg.steam_backlog_enforcer.config import Config, State
PKG = "python_pkg.steam_backlog_enforcer._enforce_loop"
class TestGetAllOwnedAppIds:
"""Tests for get_all_owned_app_ids."""
def test_from_snapshot(self) -> None:
snap = [{"app_id": 1}, {"app_id": 2}]
with patch(f"{PKG}.load_snapshot", return_value=snap):
assert get_all_owned_app_ids(Config()) == [1, 2]
def test_no_snapshot_falls_back_to_api(self) -> None:
mock_client = MagicMock()
mock_client.get_owned_games.return_value = [
{"appid": 10},
{"appid": 20},
]
with (
patch(f"{PKG}.load_snapshot", return_value=None),
patch(f"{PKG}.SteamAPIClient", return_value=mock_client),
):
result = get_all_owned_app_ids(
Config(steam_api_key="k", steam_id="i"),
)
assert result == [10, 20]
def test_api_fails(self) -> None:
with (
patch(f"{PKG}.load_snapshot", return_value=None),
patch(
f"{PKG}.SteamAPIClient",
side_effect=OSError("fail"),
),
):
assert get_all_owned_app_ids(Config()) == []
def test_empty_snapshot_falls_through_to_api(self) -> None:
mock_client = MagicMock()
mock_client.get_owned_games.return_value = [{"appid": 5}]
with (
patch(f"{PKG}.load_snapshot", return_value=[]),
patch(f"{PKG}.SteamAPIClient", return_value=mock_client),
):
assert get_all_owned_app_ids(Config(steam_api_key="k", steam_id="i")) == [5]
class TestGuardInstalledGames:
"""Tests for _guard_installed_games."""
def test_removes_unauthorized(self) -> None:
with (
patch(
f"{PKG}.get_installed_games",
return_value=[(999, "Bad Game")],
),
patch(f"{PKG}.uninstall_game", return_value=True),
patch(f"{PKG}.send_notification"),
):
assert _guard_installed_games(440) == 1
def test_skips_allowed(self) -> None:
with patch(
f"{PKG}.get_installed_games",
return_value=[(440, "TF2")],
):
assert _guard_installed_games(440) == 0
def test_skips_protected(self) -> None:
with (
patch(
f"{PKG}.get_installed_games",
return_value=[(228980, "Runtime")],
),
patch(f"{PKG}.PROTECTED_APP_IDS", {228980}),
):
assert _guard_installed_games(440) == 0
def test_uninstall_fails(self) -> None:
with (
patch(
f"{PKG}.get_installed_games",
return_value=[(999, "Bad")],
),
patch(f"{PKG}.uninstall_game", return_value=False),
):
assert _guard_installed_games(440) == 0
class TestEnforceSetup:
"""Tests for _enforce_setup."""
def test_block_store_success(self) -> None:
config = Config(block_store=True, uninstall_other_games=False)
state = State(current_app_id=1, current_game_name="G")
with (
patch(f"{PKG}.block_store", return_value=True),
patch(f"{PKG}._echo"),
patch(f"{PKG}._enforce_auto_install"),
patch(f"{PKG}._enforce_hide_games"),
):
_enforce_setup(config, state)
def test_block_store_fail(self) -> None:
config = Config(block_store=True, uninstall_other_games=False)
state = State()
with (
patch(f"{PKG}.block_store", return_value=False),
patch(f"{PKG}._echo") as mock_echo,
patch(f"{PKG}._enforce_auto_install"),
patch(f"{PKG}._enforce_hide_games"),
):
_enforce_setup(config, state)
assert any("FAILED" in str(c) for c in mock_echo.call_args_list)
def test_no_block_store(self) -> None:
config = Config(block_store=False, uninstall_other_games=False)
state = State()
with (
patch(f"{PKG}.block_store") as mock_block,
patch(f"{PKG}._echo"),
patch(f"{PKG}._enforce_auto_install"),
patch(f"{PKG}._enforce_hide_games"),
):
_enforce_setup(config, state)
mock_block.assert_not_called()
def test_uninstall_other_games(self) -> None:
config = Config(uninstall_other_games=True, block_store=False)
state = State(current_app_id=1)
with (
patch(f"{PKG}.uninstall_other_games", return_value=3),
patch(f"{PKG}._echo"),
patch(f"{PKG}._enforce_auto_install"),
patch(f"{PKG}._enforce_hide_games"),
):
_enforce_setup(config, state)
class TestEnforceAutoInstall:
"""Tests for _enforce_auto_install."""
def test_no_app_id(self) -> None:
_enforce_auto_install(Config(), State())
def test_already_installed(self) -> None:
state = State(current_app_id=1, current_game_name="G")
with (
patch(f"{PKG}.is_game_installed", return_value=True),
patch(f"{PKG}._echo"),
):
_enforce_auto_install(Config(), state)
def test_installs_successfully(self) -> None:
state = State(current_app_id=1, current_game_name="G")
with (
patch(f"{PKG}.is_game_installed", return_value=False),
patch(f"{PKG}.install_game", return_value=True),
patch(f"{PKG}.send_notification"),
patch(f"{PKG}._echo"),
):
_enforce_auto_install(Config(steam_id="i"), state)
def test_install_fails(self) -> None:
state = State(current_app_id=1, current_game_name="G")
with (
patch(f"{PKG}.is_game_installed", return_value=False),
patch(f"{PKG}.install_game", return_value=False),
patch(f"{PKG}._echo") as mock_echo,
):
_enforce_auto_install(Config(steam_id="i"), state)
assert any("manually" in str(c) for c in mock_echo.call_args_list)
class TestEnforceHideGames:
"""Tests for _enforce_hide_games."""
def test_hides_some(self) -> None:
state = State(current_app_id=1)
with (
patch(f"{PKG}.get_all_owned_app_ids", return_value=[1, 2, 3]),
patch(f"{PKG}.hide_other_games", return_value=2),
patch(f"{PKG}._echo"),
):
_enforce_hide_games(Config(), state)
def test_already_hidden(self) -> None:
state = State(current_app_id=1)
with (
patch(f"{PKG}.get_all_owned_app_ids", return_value=[1, 2]),
patch(f"{PKG}.hide_other_games", return_value=0),
patch(f"{PKG}._echo") as mock_echo,
):
_enforce_hide_games(Config(), state)
assert any("already" in str(c) for c in mock_echo.call_args_list)
def test_no_owned_ids(self) -> None:
state = State(current_app_id=1)
with (
patch(f"{PKG}.get_all_owned_app_ids", return_value=[]),
patch(f"{PKG}._echo") as mock_echo,
):
_enforce_hide_games(Config(), state)
assert any("skipped" in str(c) for c in mock_echo.call_args_list)
class TestEnforceLoopIteration:
"""Tests for _enforce_loop_iteration."""
def test_kills_unauthorized(self) -> None:
config = Config(
kill_unauthorized_games=True,
uninstall_other_games=False,
)
state = State(current_app_id=1, current_game_name="G")
with (
patch(
f"{PKG}.enforce_allowed_game",
return_value=[(1234, 999)],
),
patch(f"{PKG}.send_notification"),
patch(f"{PKG}._echo"),
patch(f"{PKG}.is_game_installed", return_value=True),
):
_enforce_loop_iteration(config, state)
def test_no_kill(self) -> None:
config = Config(
kill_unauthorized_games=False,
uninstall_other_games=False,
)
state = State(current_app_id=1, current_game_name="G")
with (
patch(f"{PKG}.enforce_allowed_game") as mock_enforce,
patch(f"{PKG}.is_game_installed", return_value=True),
):
_enforce_loop_iteration(config, state)
mock_enforce.assert_not_called()
def test_guards_installed(self) -> None:
config = Config(
kill_unauthorized_games=False,
uninstall_other_games=True,
)
state = State(current_app_id=1, current_game_name="G")
with (
patch(f"{PKG}._guard_installed_games", return_value=1),
patch(f"{PKG}._echo"),
patch(f"{PKG}.is_game_installed", return_value=True),
):
_enforce_loop_iteration(config, state)
def test_guard_removes_zero(self) -> None:
config = Config(
kill_unauthorized_games=False,
uninstall_other_games=True,
)
state = State(current_app_id=1, current_game_name="G")
with (
patch(f"{PKG}._guard_installed_games", return_value=0),
patch(f"{PKG}.is_game_installed", return_value=True),
):
_enforce_loop_iteration(config, state)
def test_reinstalls_missing(self) -> None:
config = Config(
kill_unauthorized_games=False,
uninstall_other_games=False,
)
state = State(current_app_id=1, current_game_name="G")
with (
patch(f"{PKG}.is_game_installed", return_value=False),
patch(f"{PKG}.install_game") as mock_install,
):
_enforce_loop_iteration(config, state)
mock_install.assert_called_once()
def test_no_app_id_skip_reinstall(self) -> None:
config = Config(
kill_unauthorized_games=False,
uninstall_other_games=False,
)
state = State(current_app_id=None)
with patch(f"{PKG}.is_game_installed") as mock_installed:
_enforce_loop_iteration(config, state)
mock_installed.assert_not_called()
class TestDoEnforce:
"""Tests for do_enforce."""
def test_no_game(self) -> None:
with patch(f"{PKG}._echo") as mock_echo:
do_enforce(Config(), State())
assert any("No game" in str(c) for c in mock_echo.call_args_list)
def test_keyboard_interrupt(self) -> None:
state = State(current_app_id=1, current_game_name="G")
config = Config()
fresh = State(current_app_id=1, current_game_name="G")
with (
patch(f"{PKG}._enforce_setup"),
patch(f"{PKG}._echo"),
patch.object(State, "load", return_value=fresh),
patch(
f"{PKG}._enforce_loop_iteration",
side_effect=KeyboardInterrupt,
),
patch(f"{PKG}.time.sleep"),
):
do_enforce(config, state)
def test_runs_iterations(self) -> None:
state = State(current_app_id=1, current_game_name="G")
config = Config()
fresh = State(current_app_id=1, current_game_name="G")
call_count = 0
def side_effect(*_args: object, **_kwargs: object) -> None:
nonlocal call_count
call_count += 1
if call_count >= 2:
raise KeyboardInterrupt
with (
patch(f"{PKG}._enforce_setup"),
patch(f"{PKG}._echo"),
patch.object(State, "load", return_value=fresh),
patch(
f"{PKG}._enforce_loop_iteration",
side_effect=side_effect,
),
patch(f"{PKG}.time.sleep"),
):
do_enforce(config, state)
assert call_count == 2

View File

@ -0,0 +1,192 @@
"""Tests for enforcer module."""
from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import patch
from python_pkg.steam_backlog_enforcer.enforcer import (
enforce_allowed_game,
get_running_steam_game_pids,
kill_process,
send_notification,
)
if TYPE_CHECKING:
from pathlib import Path
class TestGetRunningPids:
"""Tests for get_running_steam_game_pids."""
def test_finds_steam_pid(self, tmp_path: Path) -> None:
proc_dir = tmp_path / "proc"
pid_dir = proc_dir / "12345"
pid_dir.mkdir(parents=True)
environ = b"HOME=/home/user\x00SteamAppId=440\x00PATH=/usr/bin"
(pid_dir / "environ").write_bytes(environ)
with patch(
"python_pkg.steam_backlog_enforcer.enforcer.Path",
return_value=proc_dir,
):
result = get_running_steam_game_pids()
assert result == {12345: 440}
def test_skips_non_digit_entries(self, tmp_path: Path) -> None:
proc_dir = tmp_path / "proc"
proc_dir.mkdir(parents=True)
(proc_dir / "self").mkdir()
(proc_dir / "cpuinfo").touch()
with patch(
"python_pkg.steam_backlog_enforcer.enforcer.Path",
return_value=proc_dir,
):
result = get_running_steam_game_pids()
assert result == {}
def test_handles_permission_error(self, tmp_path: Path) -> None:
proc_dir = tmp_path / "proc"
pid_dir = proc_dir / "99"
pid_dir.mkdir(parents=True)
# No environ file -> OSError when reading
with patch(
"python_pkg.steam_backlog_enforcer.enforcer.Path",
return_value=proc_dir,
):
result = get_running_steam_game_pids()
assert result == {}
def test_skips_non_digit_steam_app_id(self, tmp_path: Path) -> None:
proc_dir = tmp_path / "proc"
pid_dir = proc_dir / "100"
pid_dir.mkdir(parents=True)
environ = b"SteamAppId=notanumber\x00"
(pid_dir / "environ").write_bytes(environ)
with patch(
"python_pkg.steam_backlog_enforcer.enforcer.Path",
return_value=proc_dir,
):
result = get_running_steam_game_pids()
assert result == {}
def test_no_steam_env(self, tmp_path: Path) -> None:
proc_dir = tmp_path / "proc"
pid_dir = proc_dir / "200"
pid_dir.mkdir(parents=True)
environ = b"HOME=/home/user\x00PATH=/usr/bin\x00"
(pid_dir / "environ").write_bytes(environ)
with patch(
"python_pkg.steam_backlog_enforcer.enforcer.Path",
return_value=proc_dir,
):
result = get_running_steam_game_pids()
assert result == {}
class TestEnforceAllowedGame:
"""Tests for enforce_allowed_game."""
def test_no_violations(self) -> None:
with patch(
"python_pkg.steam_backlog_enforcer.enforcer.get_running_steam_game_pids",
return_value={100: 440},
):
result = enforce_allowed_game(440)
assert result == []
def test_kills_unauthorized(self) -> None:
with (
patch(
"python_pkg.steam_backlog_enforcer.enforcer.get_running_steam_game_pids",
return_value={100: 570, 200: 440},
),
patch(
"python_pkg.steam_backlog_enforcer.enforcer.kill_process"
) as mock_kill,
):
result = enforce_allowed_game(440, kill_unauthorized=True)
assert result == [(100, 570)]
mock_kill.assert_called_once_with(100, 570)
def test_skips_app_id_zero(self) -> None:
with patch(
"python_pkg.steam_backlog_enforcer.enforcer.get_running_steam_game_pids",
return_value={100: 0},
):
result = enforce_allowed_game(440)
assert result == []
def test_detects_without_killing(self) -> None:
with patch(
"python_pkg.steam_backlog_enforcer.enforcer.get_running_steam_game_pids",
return_value={100: 570},
):
result = enforce_allowed_game(440, kill_unauthorized=False)
assert result == [(100, 570)]
def test_allowed_none(self) -> None:
with (
patch(
"python_pkg.steam_backlog_enforcer.enforcer.get_running_steam_game_pids",
return_value={100: 570},
),
patch(
"python_pkg.steam_backlog_enforcer.enforcer.kill_process"
) as mock_kill,
):
result = enforce_allowed_game(None, kill_unauthorized=True)
assert result == [(100, 570)]
mock_kill.assert_called_once_with(100, 570)
class TestKillProcess:
"""Tests for kill_process."""
def test_kill_success(self) -> None:
with patch("python_pkg.steam_backlog_enforcer.enforcer.os.kill") as mock_kill:
kill_process(123, 440)
mock_kill.assert_called_once()
def test_process_already_gone(self) -> None:
with patch(
"python_pkg.steam_backlog_enforcer.enforcer.os.kill",
side_effect=ProcessLookupError,
):
kill_process(123, 440) # Should not raise
def test_permission_error(self) -> None:
with patch(
"python_pkg.steam_backlog_enforcer.enforcer.os.kill",
side_effect=PermissionError,
):
kill_process(123, 440) # Should not raise
class TestSendNotification:
"""Tests for send_notification."""
def test_sends(self) -> None:
with patch(
"python_pkg.steam_backlog_enforcer.enforcer.subprocess.run"
) as mock_run:
send_notification("Title", "Body")
mock_run.assert_called_once()
def test_handles_missing_notify_send(self) -> None:
with patch(
"python_pkg.steam_backlog_enforcer.enforcer.subprocess.run",
side_effect=FileNotFoundError,
):
send_notification("Title", "Body") # Should not raise
def test_handles_os_error(self) -> None:
with patch(
"python_pkg.steam_backlog_enforcer.enforcer.subprocess.run",
side_effect=OSError,
):
send_notification("Title", "Body") # Should not raise

View File

@ -0,0 +1,475 @@
"""Tests for game_install module."""
from __future__ import annotations
import os
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
from python_pkg.steam_backlog_enforcer.game_install import (
_echo,
_ensure_steam_running,
_get_real_user,
_get_uid_gid_for_user,
_read_install_dir,
_remove_manifest,
_trigger_steam_install,
get_installed_games,
install_game,
is_game_installed,
)
if TYPE_CHECKING:
from pathlib import Path
import pytest
class TestEcho:
"""Tests for _echo."""
def test_default(self, capsys: pytest.CaptureFixture[str]) -> None:
_echo("hello")
assert capsys.readouterr().out == "hello\n"
def test_custom_end(self, capsys: pytest.CaptureFixture[str]) -> None:
_echo("hi", end="")
assert capsys.readouterr().out == "hi"
def test_empty(self, capsys: pytest.CaptureFixture[str]) -> None:
_echo()
assert capsys.readouterr().out == "\n"
def test_flush(self, capsys: pytest.CaptureFixture[str]) -> None:
_echo("x", flush=True)
assert capsys.readouterr().out == "x\n"
class TestTriggerSteamInstall:
"""Tests for _trigger_steam_install."""
def test_success(self) -> None:
with patch(
"python_pkg.steam_backlog_enforcer.game_install.subprocess.run"
) as mock_run:
result = _trigger_steam_install(440, "TF2")
assert result is True
mock_run.assert_called_once()
def test_file_not_found(self) -> None:
with patch(
"python_pkg.steam_backlog_enforcer.game_install.subprocess.run",
side_effect=FileNotFoundError,
):
result = _trigger_steam_install(440, "TF2")
assert result is False
def test_os_error(self) -> None:
with patch(
"python_pkg.steam_backlog_enforcer.game_install.subprocess.run",
side_effect=OSError,
):
result = _trigger_steam_install(440, "TF2")
assert result is False
def test_timeout(self) -> None:
import subprocess
with patch(
"python_pkg.steam_backlog_enforcer.game_install.subprocess.run",
side_effect=subprocess.TimeoutExpired("cmd", 15),
):
result = _trigger_steam_install(440, "TF2")
assert result is False
class TestGetRealUser:
"""Tests for _get_real_user."""
def test_sudo_user(self) -> None:
with patch.dict(os.environ, {"SUDO_USER": "alice", "USER": "root"}):
assert _get_real_user() == "alice"
def test_regular_user(self) -> None:
with patch.dict(os.environ, {"USER": "bob"}, clear=False):
env = os.environ.copy()
env.pop("SUDO_USER", None)
with patch.dict(os.environ, env, clear=True):
assert _get_real_user() == "bob"
class TestGetUidGid:
"""Tests for _get_uid_gid_for_user."""
def test_known_user(self) -> None:
mock_pw = MagicMock()
mock_pw.pw_uid = 1001
mock_pw.pw_gid = 1001
with patch(
"python_pkg.steam_backlog_enforcer.game_install.pwd.getpwnam",
return_value=mock_pw,
):
assert _get_uid_gid_for_user("alice") == (1001, 1001)
def test_unknown_user(self) -> None:
with patch(
"python_pkg.steam_backlog_enforcer.game_install.pwd.getpwnam",
side_effect=KeyError,
):
assert _get_uid_gid_for_user("nobody") == (1000, 1000)
class TestIsGameInstalled:
"""Tests for is_game_installed."""
def test_installed(self, tmp_path: Path) -> None:
manifest = tmp_path / "appmanifest_440.acf"
manifest.touch()
with patch(
"python_pkg.steam_backlog_enforcer.game_install.STEAMAPPS_PATH", tmp_path
):
assert is_game_installed(440) is True
def test_not_installed(self, tmp_path: Path) -> None:
with patch(
"python_pkg.steam_backlog_enforcer.game_install.STEAMAPPS_PATH", tmp_path
):
assert is_game_installed(440) is False
class TestEnsureSteamRunning:
"""Tests for _ensure_steam_running."""
def test_already_running(self) -> None:
mock_result = MagicMock(returncode=0)
with patch(
"python_pkg.steam_backlog_enforcer.game_install.subprocess.run",
return_value=mock_result,
):
_ensure_steam_running()
def test_not_running_starts_as_non_root(self) -> None:
mock_result = MagicMock(returncode=1)
with (
patch(
"python_pkg.steam_backlog_enforcer.game_install.subprocess.run",
return_value=mock_result,
),
patch(
"python_pkg.steam_backlog_enforcer.game_install.subprocess.Popen"
) as mock_popen,
patch(
"python_pkg.steam_backlog_enforcer.game_install.os.geteuid",
return_value=1000,
),
patch("python_pkg.steam_backlog_enforcer.game_install.time.sleep"),
):
_ensure_steam_running()
mock_popen.assert_called_once()
def test_not_running_starts_as_root(self) -> None:
mock_result = MagicMock(returncode=1)
mock_pw = MagicMock()
mock_pw.pw_uid = 1000
mock_pw.pw_gid = 1000
with (
patch(
"python_pkg.steam_backlog_enforcer.game_install.subprocess.run",
return_value=mock_result,
),
patch(
"python_pkg.steam_backlog_enforcer.game_install.subprocess.Popen"
) as mock_popen,
patch(
"python_pkg.steam_backlog_enforcer.game_install.os.geteuid",
return_value=0,
),
patch(
"python_pkg.steam_backlog_enforcer.game_install._get_real_user",
return_value="alice",
),
patch(
"python_pkg.steam_backlog_enforcer.game_install._get_uid_gid_for_user",
return_value=(1000, 1000),
),
patch("python_pkg.steam_backlog_enforcer.game_install.time.sleep"),
):
_ensure_steam_running()
mock_popen.assert_called_once()
def test_pgrep_not_found(self) -> None:
with (
patch(
"python_pkg.steam_backlog_enforcer.game_install.subprocess.run",
side_effect=FileNotFoundError,
),
patch("python_pkg.steam_backlog_enforcer.game_install.subprocess.Popen"),
patch(
"python_pkg.steam_backlog_enforcer.game_install.os.geteuid",
return_value=1000,
),
patch("python_pkg.steam_backlog_enforcer.game_install.time.sleep"),
):
_ensure_steam_running()
def test_steam_executable_not_found(self) -> None:
mock_result = MagicMock(returncode=1)
with (
patch(
"python_pkg.steam_backlog_enforcer.game_install.subprocess.run",
return_value=mock_result,
),
patch(
"python_pkg.steam_backlog_enforcer.game_install.subprocess.Popen",
side_effect=FileNotFoundError,
),
patch(
"python_pkg.steam_backlog_enforcer.game_install.os.geteuid",
return_value=1000,
),
):
_ensure_steam_running()
class TestInstallGame:
"""Tests for install_game."""
def test_already_installed(self, tmp_path: Path) -> None:
manifest = tmp_path / "appmanifest_440.acf"
manifest.touch()
with patch(
"python_pkg.steam_backlog_enforcer.game_install.STEAMAPPS_PATH", tmp_path
):
assert install_game(440, "TF2", "steam123") is True
def test_use_steam_protocol_success(self, tmp_path: Path) -> None:
with (
patch(
"python_pkg.steam_backlog_enforcer.game_install.STEAMAPPS_PATH",
tmp_path,
),
patch(
"python_pkg.steam_backlog_enforcer.game_install._ensure_steam_running"
),
patch(
"python_pkg.steam_backlog_enforcer.game_install._trigger_steam_install",
return_value=True,
),
):
assert install_game(440, "TF2", "s1", use_steam_protocol=True) is True
def test_use_steam_protocol_fallback(self, tmp_path: Path) -> None:
with (
patch(
"python_pkg.steam_backlog_enforcer.game_install.STEAMAPPS_PATH",
tmp_path,
),
patch(
"python_pkg.steam_backlog_enforcer.game_install._ensure_steam_running"
),
patch(
"python_pkg.steam_backlog_enforcer.game_install._trigger_steam_install",
return_value=False,
),
patch(
"python_pkg.steam_backlog_enforcer.game_install.os.geteuid",
return_value=1000,
),
):
assert install_game(440, "TF2", "s1", use_steam_protocol=True) is True
assert (tmp_path / "appmanifest_440.acf").exists()
def test_manifest_write_as_root(self, tmp_path: Path) -> None:
with (
patch(
"python_pkg.steam_backlog_enforcer.game_install.STEAMAPPS_PATH",
tmp_path,
),
patch(
"python_pkg.steam_backlog_enforcer.game_install._ensure_steam_running"
),
patch(
"python_pkg.steam_backlog_enforcer.game_install.os.geteuid",
return_value=0,
),
patch(
"python_pkg.steam_backlog_enforcer.game_install._get_real_user",
return_value="alice",
),
patch(
"python_pkg.steam_backlog_enforcer.game_install._get_uid_gid_for_user",
return_value=(1001, 1001),
),
patch(
"python_pkg.steam_backlog_enforcer.game_install.os.chown"
) as mock_chown,
):
assert install_game(440, "TF2", "s1") is True
mock_chown.assert_called_once()
def test_manifest_write_failure(self, tmp_path: Path) -> None:
# Make steamapps path not writable
with (
patch(
"python_pkg.steam_backlog_enforcer.game_install.STEAMAPPS_PATH",
tmp_path / "nonexistent" / "deep",
),
patch(
"python_pkg.steam_backlog_enforcer.game_install._ensure_steam_running"
),
patch(
"python_pkg.steam_backlog_enforcer.game_install.os.geteuid",
return_value=1000,
),
):
assert install_game(440, "TF2", "s1") is False
def test_empty_game_name(self, tmp_path: Path) -> None:
with (
patch(
"python_pkg.steam_backlog_enforcer.game_install.STEAMAPPS_PATH",
tmp_path,
),
patch(
"python_pkg.steam_backlog_enforcer.game_install._ensure_steam_running"
),
patch(
"python_pkg.steam_backlog_enforcer.game_install.os.geteuid",
return_value=1000,
),
):
assert install_game(440, "", "s1") is True
def test_manifest_not_root_no_chown(self, tmp_path: Path) -> None:
with (
patch(
"python_pkg.steam_backlog_enforcer.game_install.STEAMAPPS_PATH",
tmp_path,
),
patch(
"python_pkg.steam_backlog_enforcer.game_install._ensure_steam_running"
),
patch(
"python_pkg.steam_backlog_enforcer.game_install.os.geteuid",
return_value=1000,
),
patch(
"python_pkg.steam_backlog_enforcer.game_install.os.chown"
) as mock_chown,
):
assert install_game(440, "TF2", "s1") is True
mock_chown.assert_not_called()
def test_root_user_is_root(self, tmp_path: Path) -> None:
"""When real user IS root, don't chown."""
with (
patch(
"python_pkg.steam_backlog_enforcer.game_install.STEAMAPPS_PATH",
tmp_path,
),
patch(
"python_pkg.steam_backlog_enforcer.game_install._ensure_steam_running"
),
patch(
"python_pkg.steam_backlog_enforcer.game_install.os.geteuid",
return_value=0,
),
patch(
"python_pkg.steam_backlog_enforcer.game_install._get_real_user",
return_value="root",
),
patch(
"python_pkg.steam_backlog_enforcer.game_install.os.chown"
) as mock_chown,
):
assert install_game(440, "TF2", "s1") is True
mock_chown.assert_not_called()
class TestGetInstalledGames:
"""Tests for get_installed_games."""
def test_parses_manifests(self, tmp_path: Path) -> None:
manifest = tmp_path / "appmanifest_440.acf"
manifest.write_text('"appid"\t\t"440"\n"name"\t\t"Team Fortress 2"\n')
with patch(
"python_pkg.steam_backlog_enforcer.game_install.STEAMAPPS_PATH", tmp_path
):
result = get_installed_games()
assert result == [(440, "Team Fortress 2")]
def test_no_name(self, tmp_path: Path) -> None:
manifest = tmp_path / "appmanifest_440.acf"
manifest.write_text('"appid"\t\t"440"\n')
with patch(
"python_pkg.steam_backlog_enforcer.game_install.STEAMAPPS_PATH", tmp_path
):
result = get_installed_games()
assert result == [(440, "Unknown (440)")]
def test_empty_dir(self, tmp_path: Path) -> None:
with patch(
"python_pkg.steam_backlog_enforcer.game_install.STEAMAPPS_PATH", tmp_path
):
result = get_installed_games()
assert result == []
def test_no_appid_match(self, tmp_path: Path) -> None:
manifest = tmp_path / "appmanifest_440.acf"
manifest.write_text('"name"\t\t"NoAppId"\n')
with patch(
"python_pkg.steam_backlog_enforcer.game_install.STEAMAPPS_PATH", tmp_path
):
result = get_installed_games()
assert result == []
class TestReadInstallDir:
"""Tests for _read_install_dir."""
def test_reads_dir(self, tmp_path: Path) -> None:
manifest = tmp_path / "appmanifest_440.acf"
manifest.write_text('"installdir"\t\t"Team Fortress 2"\n')
with patch(
"python_pkg.steam_backlog_enforcer.game_install.STEAMAPPS_PATH", tmp_path
):
result = _read_install_dir(manifest)
assert result == tmp_path / "common" / "Team Fortress 2"
def test_no_match(self, tmp_path: Path) -> None:
manifest = tmp_path / "appmanifest_440.acf"
manifest.write_text('"appid"\t\t"440"\n')
with patch(
"python_pkg.steam_backlog_enforcer.game_install.STEAMAPPS_PATH", tmp_path
):
assert _read_install_dir(manifest) is None
def test_missing_file(self, tmp_path: Path) -> None:
manifest = tmp_path / "nonexistent.acf"
assert _read_install_dir(manifest) is None
def test_os_error(self, tmp_path: Path) -> None:
manifest = MagicMock()
manifest.exists.return_value = True
manifest.read_text.side_effect = OSError
assert _read_install_dir(manifest) is None
class TestRemoveManifest:
"""Tests for _remove_manifest."""
def test_removes(self, tmp_path: Path) -> None:
manifest = tmp_path / "appmanifest_440.acf"
manifest.touch()
assert _remove_manifest(manifest, "TF2", 440) is True
assert not manifest.exists()
def test_already_gone(self, tmp_path: Path) -> None:
manifest = tmp_path / "nonexistent.acf"
assert _remove_manifest(manifest, "TF2", 440) is True
def test_os_error(self) -> None:
manifest = MagicMock()
manifest.exists.return_value = True
manifest.unlink.side_effect = OSError
assert _remove_manifest(manifest, "TF2", 440) is False

View File

@ -0,0 +1,163 @@
"""Tests for game_install module — part 2 (missing coverage)."""
from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
from python_pkg.steam_backlog_enforcer.game_install import (
_remove_game_dirs,
uninstall_game,
uninstall_other_games,
)
if TYPE_CHECKING:
from pathlib import Path
PKG = "python_pkg.steam_backlog_enforcer.game_install"
class TestRemoveGameDirs:
"""Tests for _remove_game_dirs."""
def test_removes_install_dir(self, tmp_path: Path) -> None:
install_dir = tmp_path / "common" / "MyGame"
install_dir.mkdir(parents=True)
(install_dir / "game.exe").touch()
with patch(f"{PKG}.STEAMAPPS_PATH", tmp_path):
result = _remove_game_dirs(install_dir, 440)
assert result is True
assert not install_dir.exists()
def test_install_dir_none(self, tmp_path: Path) -> None:
with patch(f"{PKG}.STEAMAPPS_PATH", tmp_path):
result = _remove_game_dirs(None, 440)
assert result is True
def test_install_dir_not_exists(self, tmp_path: Path) -> None:
missing = tmp_path / "common" / "Missing"
with patch(f"{PKG}.STEAMAPPS_PATH", tmp_path):
result = _remove_game_dirs(missing, 440)
assert result is True
def test_install_dir_remove_fails(self, tmp_path: Path) -> None:
install_dir = tmp_path / "common" / "MyGame"
install_dir.mkdir(parents=True)
with (
patch(f"{PKG}.STEAMAPPS_PATH", tmp_path),
patch(f"{PKG}.shutil.rmtree", side_effect=OSError("perm")),
):
result = _remove_game_dirs(install_dir, 440)
assert result is False
def test_removes_cache_dirs(self, tmp_path: Path) -> None:
for subdir in ("shadercache", "compatdata"):
(tmp_path / subdir / "440").mkdir(parents=True)
with patch(f"{PKG}.STEAMAPPS_PATH", tmp_path):
result = _remove_game_dirs(None, 440)
assert result is True
assert not (tmp_path / "shadercache" / "440").exists()
assert not (tmp_path / "compatdata" / "440").exists()
def test_cache_dir_remove_oserror_suppressed(self, tmp_path: Path) -> None:
(tmp_path / "shadercache" / "440").mkdir(parents=True)
call_count = 0
def fake_rmtree(path: object, **_kw: object) -> None:
nonlocal call_count
call_count += 1
msg = "perm"
raise OSError(msg)
with (
patch(f"{PKG}.STEAMAPPS_PATH", tmp_path),
patch(f"{PKG}.shutil.rmtree", side_effect=fake_rmtree),
):
result = _remove_game_dirs(None, 440)
assert result is True
class TestUninstallGame:
"""Tests for uninstall_game."""
def test_success(self, tmp_path: Path) -> None:
manifest = tmp_path / "appmanifest_440.acf"
manifest.write_text('"installdir"\t\t"TF2"\n', encoding="utf-8")
install_dir = tmp_path / "common" / "TF2"
install_dir.mkdir(parents=True)
with patch(f"{PKG}.STEAMAPPS_PATH", tmp_path):
result = uninstall_game(440, "TF2")
assert result is True
def test_manifest_removal_fails(self) -> None:
mock_manifest = MagicMock()
mock_manifest.exists.return_value = True
mock_manifest.unlink.side_effect = OSError
with (
patch(f"{PKG}.STEAMAPPS_PATH", MagicMock()),
patch(f"{PKG}._read_install_dir", return_value=None),
patch(f"{PKG}._remove_manifest", return_value=False),
patch(f"{PKG}._remove_game_dirs", return_value=True),
):
result = uninstall_game(440, "TF2")
assert result is False
def test_game_dirs_removal_fails(self) -> None:
with (
patch(f"{PKG}._read_install_dir", return_value=None),
patch(f"{PKG}._remove_manifest", return_value=True),
patch(f"{PKG}._remove_game_dirs", return_value=False),
):
result = uninstall_game(440, "TF2")
assert result is False
class TestUninstallOtherGames:
"""Tests for uninstall_other_games."""
def test_keeps_allowed(self) -> None:
with (
patch(
f"{PKG}.get_installed_games",
return_value=[(440, "TF2"), (730, "CS")],
),
patch(f"{PKG}.uninstall_game", return_value=True) as mock_uninstall,
):
count = uninstall_other_games(440)
assert count == 1
mock_uninstall.assert_called_once_with(730, "CS")
def test_skips_protected(self) -> None:
with (
patch(
f"{PKG}.get_installed_games",
return_value=[(228980, "Redist")],
),
patch(f"{PKG}.uninstall_game") as mock_uninstall,
):
count = uninstall_other_games(None)
assert count == 0
mock_uninstall.assert_not_called()
def test_uninstall_fails(self) -> None:
with (
patch(
f"{PKG}.get_installed_games",
return_value=[(999, "GameX")],
),
patch(f"{PKG}.uninstall_game", return_value=False),
):
count = uninstall_other_games(None)
assert count == 0
def test_all_allowed_or_protected(self) -> None:
with (
patch(
f"{PKG}.get_installed_games",
return_value=[(440, "TF2"), (228980, "Redist")],
),
patch(f"{PKG}.uninstall_game") as mock_uninstall,
):
count = uninstall_other_games(440)
assert count == 0
mock_uninstall.assert_not_called()

View File

@ -0,0 +1,474 @@
"""Tests for hltb module."""
from __future__ import annotations
import asyncio
import json
from typing import TYPE_CHECKING, Any
from unittest.mock import AsyncMock, MagicMock, patch
import aiohttp
from typing_extensions import Self
from python_pkg.steam_backlog_enforcer.hltb import (
HLTBResult,
_build_search_payload,
_fetch_batch,
_get_auth_token,
_get_hltb_search_url,
_pick_best_hltb_entry,
_search_one,
_SearchCtx,
_similarity,
load_hltb_cache,
save_hltb_cache,
)
if TYPE_CHECKING:
from pathlib import Path
class TestHltbCache:
"""Tests for HLTB cache I/O."""
def test_load_cache_exists(self, tmp_path: Path) -> None:
cache_file = tmp_path / "hltb_cache.json"
cache_file.write_text(json.dumps({"440": 10.5}), encoding="utf-8")
with patch(
"python_pkg.steam_backlog_enforcer.hltb.HLTB_CACHE_FILE", cache_file
):
result = load_hltb_cache()
assert result == {440: 10.5}
def test_load_cache_missing(self, tmp_path: Path) -> None:
cache_file = tmp_path / "nonexistent.json"
with patch(
"python_pkg.steam_backlog_enforcer.hltb.HLTB_CACHE_FILE", cache_file
):
assert load_hltb_cache() == {}
def test_load_cache_corrupt(self, tmp_path: Path) -> None:
cache_file = tmp_path / "hltb_cache.json"
cache_file.write_text("not json", encoding="utf-8")
with patch(
"python_pkg.steam_backlog_enforcer.hltb.HLTB_CACHE_FILE", cache_file
):
assert load_hltb_cache() == {}
def test_save_cache(self, tmp_path: Path) -> None:
cache_file = tmp_path / "hltb_cache.json"
with (
patch("python_pkg.steam_backlog_enforcer.hltb.HLTB_CACHE_FILE", cache_file),
patch("python_pkg.steam_backlog_enforcer.hltb.CONFIG_DIR", tmp_path),
):
save_hltb_cache({440: 10.5})
assert cache_file.exists()
def test_save_cache_os_error(self, tmp_path: Path) -> None:
cache_file = MagicMock()
cache_file.write_text = MagicMock(side_effect=OSError)
with (
patch("python_pkg.steam_backlog_enforcer.hltb.HLTB_CACHE_FILE", cache_file),
patch(
"python_pkg.steam_backlog_enforcer.hltb.CONFIG_DIR",
MagicMock(mkdir=MagicMock()),
),
):
save_hltb_cache({440: 10.5}) # Should not raise
class TestGetHltbSearchUrl:
"""Tests for _get_hltb_search_url."""
def test_discovers_url(self) -> None:
mock_info = MagicMock()
mock_info.search_url = "/api/search/abc"
with patch("python_pkg.steam_backlog_enforcer.hltb.HTMLRequests") as mock_html:
mock_html.send_website_request_getcode.return_value = mock_info
mock_html.BASE_URL = "https://howlongtobeat.com"
url = _get_hltb_search_url()
assert url == "https://howlongtobeat.com/api/search/abc"
def test_fallback_url(self) -> None:
with patch("python_pkg.steam_backlog_enforcer.hltb.HTMLRequests") as mock_html:
mock_html.send_website_request_getcode.return_value = None
url = _get_hltb_search_url()
assert url == "https://howlongtobeat.com/api/finder"
def test_first_returns_none_second_returns_info(self) -> None:
mock_info = MagicMock()
mock_info.search_url = "/api/search/xyz"
with patch("python_pkg.steam_backlog_enforcer.hltb.HTMLRequests") as mock_html:
mock_html.send_website_request_getcode.side_effect = [None, mock_info]
mock_html.BASE_URL = "https://howlongtobeat.com"
url = _get_hltb_search_url()
assert url == "https://howlongtobeat.com/api/search/xyz"
def test_exception_fallback(self) -> None:
with patch("python_pkg.steam_backlog_enforcer.hltb.HTMLRequests") as mock_html:
mock_html.send_website_request_getcode.side_effect = RuntimeError
url = _get_hltb_search_url()
assert url == "https://howlongtobeat.com/api/finder"
class TestGetAuthToken:
"""Tests for _get_auth_token."""
def test_success(self) -> None:
mock_resp = AsyncMock()
mock_resp.status = 200
mock_resp.json = AsyncMock(return_value={"token": "abc123"})
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
mock_resp.__aexit__ = AsyncMock(return_value=False)
mock_session = MagicMock()
mock_session.get = MagicMock(return_value=mock_resp)
result = asyncio.run(
_get_auth_token("https://howlongtobeat.com/api/finder", mock_session)
)
assert result == "abc123"
def test_non_200(self) -> None:
mock_resp = AsyncMock()
mock_resp.status = 500
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
mock_resp.__aexit__ = AsyncMock(return_value=False)
mock_session = MagicMock()
mock_session.get = MagicMock(return_value=mock_resp)
result = asyncio.run(
_get_auth_token("https://howlongtobeat.com/api/finder", mock_session)
)
assert result is None
def test_client_error(self) -> None:
mock_session = MagicMock()
ctx = AsyncMock()
ctx.__aenter__ = AsyncMock(side_effect=aiohttp.ClientError)
ctx.__aexit__ = AsyncMock(return_value=False)
mock_session.get = MagicMock(return_value=ctx)
result = asyncio.run(
_get_auth_token("https://howlongtobeat.com/api/finder", mock_session)
)
assert result is None
class TestSimilarity:
"""Tests for _similarity."""
def test_identical(self) -> None:
assert _similarity("hello", "hello") == 1.0
def test_different(self) -> None:
assert _similarity("abc", "xyz") < 0.5
def test_case_insensitive(self) -> None:
assert _similarity("Hello", "hello") == 1.0
class TestBuildSearchPayload:
"""Tests for _build_search_payload."""
def test_returns_json(self) -> None:
payload = _build_search_payload("Half-Life 2")
data = json.loads(payload)
assert data["searchType"] == "games"
assert data["searchTerms"] == ["Half-Life", "2"]
class TestPickBestHltbEntry:
"""Tests for _pick_best_hltb_entry."""
def test_empty(self) -> None:
assert _pick_best_hltb_entry("game", []) is None
def test_single(self) -> None:
entry: dict[str, Any] = {"game_name": "Game", "comp_100": 3600}
result = _pick_best_hltb_entry("Game", [(entry, 1.0)])
assert result is not None
assert result[0]["game_name"] == "Game"
def test_prefers_full_edition_colon(self) -> None:
demo: dict[str, Any] = {"game_name": "FAITH", "comp_100": 1800}
full: dict[str, Any] = {
"game_name": "FAITH: The Unholy Trinity",
"comp_100": 7200,
}
result = _pick_best_hltb_entry("FAITH", [(demo, 1.0), (full, 0.8)])
assert result is not None
assert result[0]["game_name"] == "FAITH: The Unholy Trinity"
def test_prefers_full_edition_dash(self) -> None:
demo: dict[str, Any] = {"game_name": "FAITH", "comp_100": 1800}
full: dict[str, Any] = {"game_name": "FAITH - Complete", "comp_100": 7200}
result = _pick_best_hltb_entry("FAITH", [(demo, 1.0), (full, 0.8)])
assert result is not None
assert result[0]["game_name"] == "FAITH - Complete"
def test_falls_back_to_highest_similarity(self) -> None:
a: dict[str, Any] = {"game_name": "ABC", "comp_100": 3600}
b: dict[str, Any] = {"game_name": "DEF", "comp_100": 7200}
result = _pick_best_hltb_entry("ABC", [(a, 0.9), (b, 0.7)])
assert result is not None
assert result[1] == 0.9
class _FakeResponse:
"""Async context manager mimicking aiohttp response."""
def __init__(self, status: int, json_data: dict[str, Any] | None = None) -> None:
self.status = status
self._json_data = json_data or {}
async def __aenter__(self) -> Self:
return self
async def __aexit__(self, *args: object) -> None:
pass
async def json(self) -> dict[str, Any]:
return self._json_data
def _make_session(resp: _FakeResponse) -> MagicMock:
session = MagicMock()
session.post.return_value = resp
return session
def _make_ctx(
session: MagicMock,
*,
cache: dict[int, float] | None = None,
progress_cb: Any = None,
) -> _SearchCtx:
return _SearchCtx(
session=session,
search_url="https://example.com/search",
headers={},
cache=cache if cache is not None else {},
counter={"done": 0, "found": 0},
total=1,
progress_cb=progress_cb,
)
class TestSearchOne:
"""Tests for _search_one."""
def test_found(self) -> None:
resp = _FakeResponse(
200,
{
"data": [
{
"game_name": "TF2",
"game_alias": "",
"comp_100": 180000,
"game_id": 12345,
}
],
},
)
ctx = _make_ctx(_make_session(resp))
result = asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2"))
assert result is not None
assert result.app_id == 440
def test_not_found(self) -> None:
resp = _FakeResponse(200, {"data": []})
ctx = _make_ctx(_make_session(resp))
result = asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2"))
assert result is None
assert ctx.cache[440] == -1
def test_error(self) -> None:
session = MagicMock()
session.post.side_effect = aiohttp.ClientError("fail")
ctx = _make_ctx(session)
result = asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2"))
assert result is None
def test_non_200(self) -> None:
resp = _FakeResponse(500)
ctx = _make_ctx(_make_session(resp))
result = asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2"))
assert result is None
def test_with_progress_cb(self) -> None:
resp = _FakeResponse(200, {"data": []})
cb = MagicMock()
ctx = _make_ctx(_make_session(resp), progress_cb=cb)
asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2"))
cb.assert_called_once()
def test_low_similarity_skipped(self) -> None:
resp = _FakeResponse(
200,
{
"data": [
{
"game_name": "Completely Different Name",
"game_alias": "",
"comp_100": 3600,
"game_id": 1,
}
],
},
)
ctx = _make_ctx(_make_session(resp))
result = asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2"))
assert result is None
def test_zero_comp_100_skipped(self) -> None:
resp = _FakeResponse(
200,
{
"data": [
{
"game_name": "TF2",
"game_alias": "",
"comp_100": 0,
"game_id": 1,
}
],
},
)
ctx = _make_ctx(_make_session(resp))
result = asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2"))
assert result is None
def test_alias_match(self) -> None:
resp = _FakeResponse(
200,
{
"data": [
{
"game_name": "Team Fortress 2",
"game_alias": "TF2",
"comp_100": 180000,
"game_id": 12345,
}
],
},
)
ctx = _make_ctx(_make_session(resp))
result = asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2"))
assert result is not None
def test_full_edition_colon(self) -> None:
resp = _FakeResponse(
200,
{
"data": [
{
"game_name": "TF2: Complete",
"game_alias": "",
"comp_100": 180000,
"game_id": 99,
}
],
},
)
ctx = _make_ctx(_make_session(resp))
result = asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2"))
assert result is not None
def test_full_edition_dash(self) -> None:
resp = _FakeResponse(
200,
{
"data": [
{
"game_name": "TF2 - Complete",
"game_alias": "",
"comp_100": 180000,
"game_id": 99,
}
],
},
)
ctx = _make_ctx(_make_session(resp))
result = asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2"))
assert result is not None
def test_save_interval(self) -> None:
"""Trigger the _SAVE_INTERVAL branch."""
resp = _FakeResponse(200, {"data": []})
ctx = _make_ctx(_make_session(resp))
# Set done to one less than _SAVE_INTERVAL so it triggers save
from python_pkg.steam_backlog_enforcer.hltb import _SAVE_INTERVAL
ctx.counter["done"] = _SAVE_INTERVAL - 1
with patch(
"python_pkg.steam_backlog_enforcer.hltb.save_hltb_cache"
) as mock_save:
asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2"))
mock_save.assert_called_once()
class TestFetchBatchHltb:
"""Tests for _fetch_batch (the hltb version)."""
def test_no_token(self) -> None:
with (
patch(
"python_pkg.steam_backlog_enforcer.hltb._get_hltb_search_url",
return_value="https://example.com",
),
patch(
"python_pkg.steam_backlog_enforcer.hltb._get_auth_token",
new_callable=AsyncMock,
return_value=None,
),
):
results = asyncio.run(_fetch_batch([(440, "TF2")], {}, None))
assert results == []
def test_with_token(self) -> None:
with (
patch(
"python_pkg.steam_backlog_enforcer.hltb._get_hltb_search_url",
return_value="https://example.com",
),
patch(
"python_pkg.steam_backlog_enforcer.hltb._get_auth_token",
new_callable=AsyncMock,
return_value="token123",
),
patch(
"python_pkg.steam_backlog_enforcer.hltb._search_one",
new_callable=AsyncMock,
return_value=HLTBResult(
app_id=440,
game_name="TF2",
completionist_hours=50.0,
similarity=1.0,
),
),
):
results = asyncio.run(_fetch_batch([(440, "TF2")], {}, None))
assert len(results) == 1
def test_filters_none_results(self) -> None:
with (
patch(
"python_pkg.steam_backlog_enforcer.hltb._get_hltb_search_url",
return_value="https://example.com",
),
patch(
"python_pkg.steam_backlog_enforcer.hltb._get_auth_token",
new_callable=AsyncMock,
return_value="token123",
),
patch(
"python_pkg.steam_backlog_enforcer.hltb._search_one",
new_callable=AsyncMock,
return_value=None,
),
):
results = asyncio.run(_fetch_batch([(440, "TF2")], {}, None))
assert results == []

View File

@ -0,0 +1,135 @@
"""Tests for hltb module — part 2 (missing coverage)."""
from __future__ import annotations
from unittest.mock import MagicMock, patch
from python_pkg.steam_backlog_enforcer.hltb import (
HLTB_BASE_URL,
HLTBResult,
fetch_hltb_times_cached,
get_hltb_submit_url,
)
PKG = "python_pkg.steam_backlog_enforcer.hltb"
class TestFetchHltbTimesCached:
"""Tests for fetch_hltb_times_cached."""
def test_all_cached(self) -> None:
with (
patch(f"{PKG}.load_hltb_cache", return_value={440: 50.0}),
):
result = fetch_hltb_times_cached([(440, "TF2")])
assert result == {440: 50.0}
def test_uncached_games_fetched(self) -> None:
with (
patch(f"{PKG}.load_hltb_cache", return_value={440: 50.0}),
patch(f"{PKG}.fetch_hltb_times") as mock_fetch,
patch(f"{PKG}.save_hltb_cache") as mock_save,
patch(f"{PKG}.time.monotonic", side_effect=[0.0, 2.0]),
):
# fetch_hltb_times modifies cache in-place
def add_to_cache(
games: object,
cache: dict[int, float] | None = None,
progress_cb: object = None,
) -> list[object]:
if cache is not None:
cache[730] = 20.0
return []
mock_fetch.side_effect = add_to_cache
result = fetch_hltb_times_cached(
[(440, "TF2"), (730, "CS")],
)
assert result[440] == 50.0
assert result[730] == 20.0
mock_save.assert_called_once()
def test_uncached_with_progress_cb(self) -> None:
cb = MagicMock()
with (
patch(f"{PKG}.load_hltb_cache", return_value={}),
patch(f"{PKG}.fetch_hltb_times") as mock_fetch,
patch(f"{PKG}.save_hltb_cache"),
patch(f"{PKG}.time.monotonic", side_effect=[0.0, 1.0]),
):
mock_fetch.return_value = []
result = fetch_hltb_times_cached(
[(440, "TF2")],
progress_cb=cb,
)
assert 440 not in result or result.get(440) == -1
def test_uncached_zero_elapsed(self) -> None:
"""Covers the elapsed == 0 branch for rate calculation."""
with (
patch(f"{PKG}.load_hltb_cache", return_value={}),
patch(f"{PKG}.fetch_hltb_times") as mock_fetch,
patch(f"{PKG}.save_hltb_cache"),
patch(f"{PKG}.time.monotonic", side_effect=[5.0, 5.0]),
):
mock_fetch.return_value = []
fetch_hltb_times_cached([(440, "TF2")])
def test_found_count(self) -> None:
"""Covers the found count in logging."""
with (
patch(f"{PKG}.load_hltb_cache", return_value={}),
patch(f"{PKG}.fetch_hltb_times") as mock_fetch,
patch(f"{PKG}.save_hltb_cache"),
patch(f"{PKG}.time.monotonic", side_effect=[0.0, 3.0]),
):
def add_found(
games: object,
cache: dict[int, float] | None = None,
progress_cb: object = None,
) -> list[object]:
if cache is not None:
cache[440] = 50.0
cache[730] = -1
return []
mock_fetch.side_effect = add_found
result = fetch_hltb_times_cached(
[(440, "TF2"), (730, "CS")],
)
assert result[440] == 50.0
assert result[730] == -1
class TestGetHltbSubmitUrl:
"""Tests for get_hltb_submit_url."""
def test_found(self) -> None:
mock_result = HLTBResult(
app_id=0,
game_name="TF2",
completionist_hours=50.0,
similarity=1.0,
hltb_game_id=12345,
)
with patch(f"{PKG}.fetch_hltb_times", return_value=[mock_result]):
url = get_hltb_submit_url("TF2")
assert url == f"{HLTB_BASE_URL}/submit/game/12345"
def test_not_found_empty(self) -> None:
with patch(f"{PKG}.fetch_hltb_times", return_value=[]):
url = get_hltb_submit_url("Unknown Game")
assert url is None
def test_not_found_no_id(self) -> None:
mock_result = HLTBResult(
app_id=0,
game_name="TF2",
completionist_hours=50.0,
similarity=1.0,
hltb_game_id=0,
)
with patch(f"{PKG}.fetch_hltb_times", return_value=[mock_result]):
url = get_hltb_submit_url("TF2")
assert url is None

View File

@ -0,0 +1,45 @@
"""Tests for hltb module - part 3 (fetch_hltb_times)."""
from __future__ import annotations
from unittest.mock import patch
from python_pkg.steam_backlog_enforcer.hltb import (
HLTBResult,
fetch_hltb_times,
)
class TestFetchHltbTimes:
"""Tests for fetch_hltb_times."""
def test_empty(self) -> None:
assert fetch_hltb_times([]) == []
def test_calls_batch(self) -> None:
mock_result = HLTBResult(
app_id=440, game_name="TF2", completionist_hours=50.0, similarity=1.0
)
with patch(
"python_pkg.steam_backlog_enforcer.hltb._fetch_batch",
return_value=[mock_result],
):
results = fetch_hltb_times([(440, "TF2")])
assert len(results) == 1
def test_none_cache(self) -> None:
with patch(
"python_pkg.steam_backlog_enforcer.hltb._fetch_batch",
return_value=[],
):
results = fetch_hltb_times([(440, "TF2")])
assert results == []
def test_explicit_cache(self) -> None:
with patch(
"python_pkg.steam_backlog_enforcer.hltb._fetch_batch",
return_value=[],
):
cache: dict[int, float] = {440: 10.0}
results = fetch_hltb_times([(440, "TF2")], cache=cache)
assert results == []

View File

@ -0,0 +1,497 @@
"""Tests for library_hider module."""
from __future__ import annotations
import asyncio
import json
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from python_pkg.steam_backlog_enforcer.library_hider import (
_cdp_result_value,
_evaluate_js,
_evaluate_js_async,
_get_shared_js_ws_url,
_is_steam_running,
_launch_steam_with_debug,
_shutdown_steam,
_steam_has_debug_port,
_wait_for_cdp_ready,
_wait_for_collections_ready,
ensure_steam_debug_port,
hide_other_games,
unhide_all_games,
)
class TestGetSharedJsWsUrl:
"""Tests for _get_shared_js_ws_url."""
def test_finds_url(self) -> None:
targets = [
{
"title": "SharedJSContext",
"webSocketDebuggerUrl": "ws://127.0.0.1:8080/x",
},
{"title": "Other", "webSocketDebuggerUrl": "ws://other"},
]
mock_resp = MagicMock()
mock_resp.read.return_value = json.dumps(targets).encode()
mock_resp.__enter__ = MagicMock(return_value=mock_resp)
mock_resp.__exit__ = MagicMock(return_value=False)
with patch(
"python_pkg.steam_backlog_enforcer.library_hider.urllib.request.urlopen",
return_value=mock_resp,
):
result = _get_shared_js_ws_url()
assert result == "ws://127.0.0.1:8080/x"
def test_no_shared_context(self) -> None:
targets = [{"title": "Other", "webSocketDebuggerUrl": "ws://other"}]
mock_resp = MagicMock()
mock_resp.read.return_value = json.dumps(targets).encode()
mock_resp.__enter__ = MagicMock(return_value=mock_resp)
mock_resp.__exit__ = MagicMock(return_value=False)
with patch(
"python_pkg.steam_backlog_enforcer.library_hider.urllib.request.urlopen",
return_value=mock_resp,
):
assert _get_shared_js_ws_url() is None
def test_connection_error(self) -> None:
with patch(
"python_pkg.steam_backlog_enforcer.library_hider.urllib.request.urlopen",
side_effect=OSError,
):
assert _get_shared_js_ws_url() is None
class TestEvaluateJsAsync:
"""Tests for _evaluate_js_async."""
def test_success(self) -> None:
mock_ws = AsyncMock()
mock_ws.send = AsyncMock()
mock_ws.recv = AsyncMock(
return_value=json.dumps({"result": {"result": {"value": "ok"}}})
)
mock_ws.__aenter__ = AsyncMock(return_value=mock_ws)
mock_ws.__aexit__ = AsyncMock(return_value=False)
with patch(
"python_pkg.steam_backlog_enforcer.library_hider.websockets.connect",
return_value=mock_ws,
):
result = asyncio.run(_evaluate_js_async("ws://test", "1+1"))
assert result["result"]["result"]["value"] == "ok"
class TestEvaluateJs:
"""Tests for _evaluate_js."""
def test_success(self) -> None:
with (
patch(
"python_pkg.steam_backlog_enforcer.library_hider._get_shared_js_ws_url",
return_value="ws://test",
),
patch(
"python_pkg.steam_backlog_enforcer.library_hider.asyncio.run",
return_value={"result": {"result": {"value": "ok"}}},
),
):
result = _evaluate_js("1+1")
assert result["result"]["result"]["value"] == "ok"
def test_no_ws_url(self) -> None:
with (
patch(
"python_pkg.steam_backlog_enforcer.library_hider._get_shared_js_ws_url",
return_value=None,
),
pytest.raises(RuntimeError, match="SharedJSContext not found"),
):
_evaluate_js("1+1")
class TestCdpResultValue:
"""Tests for _cdp_result_value."""
def test_extracts_value(self) -> None:
result = {"result": {"result": {"value": "hello"}}}
assert _cdp_result_value(result) == "hello"
def test_exception(self) -> None:
result = {
"result": {
"result": {"description": "Error!"},
"exceptionDetails": {},
}
}
with pytest.raises(RuntimeError, match="JS evaluation error"):
_cdp_result_value(result)
def test_empty(self) -> None:
assert _cdp_result_value({}) == ""
class TestIsSteamRunning:
"""Tests for _is_steam_running."""
def test_running(self) -> None:
mock_result = MagicMock(returncode=0)
with patch(
"python_pkg.steam_backlog_enforcer.library_hider.subprocess.run",
return_value=mock_result,
):
assert _is_steam_running() is True
def test_not_running(self) -> None:
mock_result = MagicMock(returncode=1)
with patch(
"python_pkg.steam_backlog_enforcer.library_hider.subprocess.run",
return_value=mock_result,
):
assert _is_steam_running() is False
class TestSteamHasDebugPort:
"""Tests for _steam_has_debug_port."""
def test_has_port(self) -> None:
with patch(
"python_pkg.steam_backlog_enforcer.library_hider._get_shared_js_ws_url",
return_value="ws://test",
):
assert _steam_has_debug_port() is True
def test_no_port(self) -> None:
with patch(
"python_pkg.steam_backlog_enforcer.library_hider._get_shared_js_ws_url",
return_value=None,
):
assert _steam_has_debug_port() is False
class TestWaitForCdpReady:
"""Tests for _wait_for_cdp_ready."""
def test_ready_immediately(self) -> None:
with patch(
"python_pkg.steam_backlog_enforcer.library_hider._get_shared_js_ws_url",
return_value="ws://test",
):
assert _wait_for_cdp_ready() is True
def test_timeout(self) -> None:
with (
patch(
"python_pkg.steam_backlog_enforcer.library_hider._get_shared_js_ws_url",
return_value=None,
),
patch(
"python_pkg.steam_backlog_enforcer.library_hider.time.sleep",
),
patch(
"python_pkg.steam_backlog_enforcer.library_hider._STEAM_STARTUP_WAIT",
2,
),
):
assert _wait_for_cdp_ready() is False
class TestWaitForCollectionsReady:
"""Tests for _wait_for_collections_ready."""
def test_ready(self) -> None:
with (
patch(
"python_pkg.steam_backlog_enforcer.library_hider._evaluate_js",
return_value={"result": {"result": {"value": "ok"}}},
),
patch(
"python_pkg.steam_backlog_enforcer.library_hider._cdp_result_value",
return_value="ok",
),
):
assert _wait_for_collections_ready() is True
def test_not_ready_then_ready(self) -> None:
with (
patch(
"python_pkg.steam_backlog_enforcer.library_hider._evaluate_js",
return_value={"result": {"result": {"value": "not_ready"}}},
),
patch(
"python_pkg.steam_backlog_enforcer.library_hider._cdp_result_value",
side_effect=["not_ready", "ok"],
),
patch(
"python_pkg.steam_backlog_enforcer.library_hider.time.sleep",
),
patch(
"python_pkg.steam_backlog_enforcer.library_hider._STEAM_STARTUP_WAIT",
2,
),
):
assert _wait_for_collections_ready() is True
def test_timeout(self) -> None:
with (
patch(
"python_pkg.steam_backlog_enforcer.library_hider._evaluate_js",
side_effect=RuntimeError,
),
patch(
"python_pkg.steam_backlog_enforcer.library_hider.time.sleep",
),
patch(
"python_pkg.steam_backlog_enforcer.library_hider._STEAM_STARTUP_WAIT",
2,
),
):
assert _wait_for_collections_ready() is False
class TestShutdownSteam:
"""Tests for _shutdown_steam."""
def test_exits_immediately(self) -> None:
mock_result = MagicMock(returncode=1) # Not running
with (
patch(
"python_pkg.steam_backlog_enforcer.library_hider._run_as_user",
),
patch(
"python_pkg.steam_backlog_enforcer.library_hider.subprocess.run",
return_value=mock_result,
),
):
_shutdown_steam()
def test_waits_for_exit(self) -> None:
results = [MagicMock(returncode=0), MagicMock(returncode=1)]
with (
patch(
"python_pkg.steam_backlog_enforcer.library_hider._run_as_user",
),
patch(
"python_pkg.steam_backlog_enforcer.library_hider.subprocess.run",
side_effect=results,
),
patch(
"python_pkg.steam_backlog_enforcer.library_hider.time.sleep",
),
):
_shutdown_steam()
def test_file_not_found(self) -> None:
with patch(
"python_pkg.steam_backlog_enforcer.library_hider._run_as_user",
side_effect=FileNotFoundError,
):
_shutdown_steam() # Should not raise
def test_timeout(self) -> None:
mock_result = MagicMock(returncode=0) # Still running
with (
patch(
"python_pkg.steam_backlog_enforcer.library_hider._run_as_user",
),
patch(
"python_pkg.steam_backlog_enforcer.library_hider.subprocess.run",
return_value=mock_result,
),
patch(
"python_pkg.steam_backlog_enforcer.library_hider.time.sleep",
),
):
_shutdown_steam() # Should complete loop without raising
class TestLaunchSteamWithDebug:
"""Tests for _launch_steam_with_debug."""
def test_launches(self) -> None:
with patch(
"python_pkg.steam_backlog_enforcer.library_hider._run_as_user",
) as mock_run:
_launch_steam_with_debug()
mock_run.assert_called_once()
class TestEnsureSteamDebugPort:
"""Tests for ensure_steam_debug_port."""
def test_already_available(self) -> None:
with patch(
"python_pkg.steam_backlog_enforcer.library_hider._steam_has_debug_port",
return_value=True,
):
ensure_steam_debug_port()
def test_starts_fresh(self) -> None:
with (
patch(
"python_pkg.steam_backlog_enforcer.library_hider._steam_has_debug_port",
return_value=False,
),
patch(
"python_pkg.steam_backlog_enforcer.library_hider._is_steam_running",
return_value=False,
),
patch(
"python_pkg.steam_backlog_enforcer.library_hider._launch_steam_with_debug",
),
patch(
"python_pkg.steam_backlog_enforcer.library_hider._wait_for_cdp_ready",
return_value=True,
),
patch(
"python_pkg.steam_backlog_enforcer.library_hider._wait_for_collections_ready",
return_value=True,
),
):
ensure_steam_debug_port()
def test_restarts_running_steam(self) -> None:
with (
patch(
"python_pkg.steam_backlog_enforcer.library_hider._steam_has_debug_port",
return_value=False,
),
patch(
"python_pkg.steam_backlog_enforcer.library_hider._is_steam_running",
return_value=True,
),
patch(
"python_pkg.steam_backlog_enforcer.library_hider._shutdown_steam",
),
patch(
"python_pkg.steam_backlog_enforcer.library_hider._launch_steam_with_debug",
),
patch(
"python_pkg.steam_backlog_enforcer.library_hider._wait_for_cdp_ready",
return_value=True,
),
patch(
"python_pkg.steam_backlog_enforcer.library_hider._wait_for_collections_ready",
return_value=True,
),
):
ensure_steam_debug_port()
def test_cdp_timeout(self) -> None:
with (
patch(
"python_pkg.steam_backlog_enforcer.library_hider._steam_has_debug_port",
return_value=False,
),
patch(
"python_pkg.steam_backlog_enforcer.library_hider._is_steam_running",
return_value=False,
),
patch(
"python_pkg.steam_backlog_enforcer.library_hider._launch_steam_with_debug",
),
patch(
"python_pkg.steam_backlog_enforcer.library_hider._wait_for_cdp_ready",
return_value=False,
),
pytest.raises(RuntimeError, match="Timed out waiting for Steam CDP"),
):
ensure_steam_debug_port()
def test_collections_timeout(self) -> None:
with (
patch(
"python_pkg.steam_backlog_enforcer.library_hider._steam_has_debug_port",
return_value=False,
),
patch(
"python_pkg.steam_backlog_enforcer.library_hider._is_steam_running",
return_value=False,
),
patch(
"python_pkg.steam_backlog_enforcer.library_hider._launch_steam_with_debug",
),
patch(
"python_pkg.steam_backlog_enforcer.library_hider._wait_for_cdp_ready",
return_value=True,
),
patch(
"python_pkg.steam_backlog_enforcer.library_hider._wait_for_collections_ready",
return_value=False,
),
pytest.raises(
RuntimeError, match="Timed out waiting for Steam collections"
),
):
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": '{"newlyHidden": 5}'}}},
),
patch(
"python_pkg.steam_backlog_enforcer.library_hider._cdp_result_value",
return_value='{"newlyHidden": 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",
):
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": '{"newlyHidden": 2}'}}},
),
patch(
"python_pkg.steam_backlog_enforcer.library_hider._cdp_result_value",
return_value='{"newlyHidden": 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

View File

@ -0,0 +1,114 @@
"""Tests for library_hider module — part 2 (missing coverage)."""
from __future__ import annotations
import os
from unittest.mock import MagicMock, patch
from python_pkg.steam_backlog_enforcer.library_hider import (
_run_as_user,
restart_steam,
)
PKG = "python_pkg.steam_backlog_enforcer.library_hider"
class TestRunAsUser:
"""Tests for _run_as_user."""
def test_non_root_runs_directly(self) -> None:
with (
patch(f"{PKG}.os.geteuid", return_value=1000),
patch(f"{PKG}.subprocess.Popen") as mock_popen,
):
_run_as_user(["steam", "-shutdown"], "alice")
mock_popen.assert_called_once()
cmd = mock_popen.call_args[0][0]
assert cmd == ["steam", "-shutdown"]
def test_root_drops_to_user(self) -> None:
mock_pw = MagicMock()
mock_pw.pw_uid = 1001
with (
patch(f"{PKG}.os.geteuid", return_value=0),
patch(f"{PKG}.pwd.getpwnam", return_value=mock_pw),
patch.dict(os.environ, {"DISPLAY": ":1", "XAUTHORITY": "/tmp/.X"}),
patch(f"{PKG}.subprocess.Popen") as mock_popen,
):
_run_as_user(["steam", "-shutdown"], "alice")
mock_popen.assert_called_once()
cmd = mock_popen.call_args[0][0]
assert cmd[0] == "sudo"
assert "-u" in cmd
assert "alice" in cmd
def test_root_user_key_error(self) -> None:
with (
patch(f"{PKG}.os.geteuid", return_value=0),
patch(f"{PKG}.pwd.getpwnam", side_effect=KeyError("no user")),
patch(f"{PKG}.subprocess.Popen") as mock_popen,
):
_run_as_user(["steam"], "unknownuser")
mock_popen.assert_called_once()
cmd = mock_popen.call_args[0][0]
# Falls back to uid 1000
assert "sudo" in cmd[0]
def test_root_user_none(self) -> None:
"""When user is None and euid is 0, runs directly."""
with (
patch(f"{PKG}.os.geteuid", return_value=0),
patch(f"{PKG}.subprocess.Popen") as mock_popen,
):
_run_as_user(["steam"], None)
cmd = mock_popen.call_args[0][0]
assert cmd == ["steam"]
def test_root_user_is_root(self) -> None:
"""When user is 'root', runs directly."""
with (
patch(f"{PKG}.os.geteuid", return_value=0),
patch(f"{PKG}.subprocess.Popen") as mock_popen,
):
_run_as_user(["steam"], "root")
cmd = mock_popen.call_args[0][0]
assert cmd == ["steam"]
def test_root_uses_env_defaults(self) -> None:
"""When DBUS/XAUTHORITY/DISPLAY not in env, uses defaults."""
mock_pw = MagicMock()
mock_pw.pw_uid = 1000
env_copy = os.environ.copy()
env_copy.pop("DBUS_SESSION_BUS_ADDRESS", None)
env_copy.pop("XAUTHORITY", None)
env_copy.pop("DISPLAY", None)
with (
patch(f"{PKG}.os.geteuid", return_value=0),
patch(f"{PKG}.pwd.getpwnam", return_value=mock_pw),
patch.dict(os.environ, env_copy, clear=True),
patch(f"{PKG}.subprocess.Popen") as mock_popen,
):
_run_as_user(["steam"], "bob")
cmd = mock_popen.call_args[0][0]
assert any("DISPLAY=:0" in arg for arg in cmd)
assert any("/home/bob/.Xauthority" in arg for arg in cmd)
class TestRestartSteam:
"""Tests for restart_steam."""
def test_cdp_ready(self) -> None:
with (
patch(f"{PKG}._shutdown_steam"),
patch(f"{PKG}._launch_steam_with_debug"),
patch(f"{PKG}._wait_for_cdp_ready", return_value=True),
):
restart_steam()
def test_cdp_not_ready(self) -> None:
with (
patch(f"{PKG}._shutdown_steam"),
patch(f"{PKG}._launch_steam_with_debug"),
patch(f"{PKG}._wait_for_cdp_ready", return_value=False),
):
restart_steam()

View File

@ -0,0 +1,483 @@
"""Tests for main CLI module."""
from __future__ import annotations
from typing import Any
from unittest.mock import patch
from python_pkg.steam_backlog_enforcer.config import Config, State
from python_pkg.steam_backlog_enforcer.main import (
_try_reassign_shorter_game,
cmd_buy_dlc,
cmd_hide,
cmd_install,
cmd_installed,
cmd_list,
cmd_reset,
cmd_setup,
cmd_status,
cmd_unblock,
cmd_unhide,
cmd_uninstall,
)
from python_pkg.steam_backlog_enforcer.steam_api import GameInfo
PKG = "python_pkg.steam_backlog_enforcer.main"
def _snap(
app_id: int = 1,
name: str = "G",
total: int = 10,
unlocked: int = 0,
hours: float = -1,
) -> dict[str, Any]:
return {
"app_id": app_id,
"name": name,
"total_achievements": total,
"unlocked_achievements": unlocked,
"playtime_minutes": 60,
"completionist_hours": hours,
}
class TestCmdStatus:
"""Tests for cmd_status."""
def test_with_game(self) -> None:
state = State(current_app_id=440, current_game_name="TF2")
with (
patch(f"{PKG}.is_store_blocked", return_value=True),
patch(f"{PKG}.get_installed_games", return_value=[(440, "TF2")]),
patch(f"{PKG}._echo"),
):
cmd_status(Config(), state)
def test_no_game(self) -> None:
with (
patch(f"{PKG}.is_store_blocked", return_value=False),
patch(f"{PKG}.get_installed_games", return_value=[]),
patch(f"{PKG}._echo"),
):
cmd_status(Config(), State())
class TestCmdList:
"""Tests for cmd_list."""
def test_no_snapshot(self) -> None:
with (
patch(f"{PKG}.load_snapshot", return_value=None),
patch(f"{PKG}._echo") as mock_echo,
):
cmd_list(Config(), State())
assert any("No snapshot" in str(c) for c in mock_echo.call_args_list)
def test_with_games(self) -> None:
snap = [
_snap(1, "A", 10, 5, 20.0),
_snap(2, "B", 10, 10, 10.0),
_snap(3, "C", 10, 3, -1),
]
state = State(current_app_id=1)
with (
patch(f"{PKG}.load_snapshot", return_value=snap),
patch(f"{PKG}._echo"),
):
cmd_list(Config(), state)
def test_many_games(self) -> None:
snap = [_snap(i, f"Game{i}") for i in range(60)]
with (
patch(f"{PKG}.load_snapshot", return_value=snap),
patch(f"{PKG}._echo") as mock_echo,
):
cmd_list(Config(), State())
assert any("more" in str(c) for c in mock_echo.call_args_list)
class TestCmdUnblock:
"""Tests for cmd_unblock."""
def test_success(self) -> None:
with (
patch(f"{PKG}.unblock_store", return_value=True),
patch(f"{PKG}._echo"),
):
cmd_unblock(Config(), State())
def test_fail(self) -> None:
with (
patch(f"{PKG}.unblock_store", return_value=False),
patch(f"{PKG}._echo") as mock_echo,
):
cmd_unblock(Config(), State())
assert any("Failed" in str(c) for c in mock_echo.call_args_list)
class TestCmdBuyDlc:
"""Tests for cmd_buy_dlc."""
def test_no_game(self) -> None:
with patch(f"{PKG}._echo") as mock_echo:
cmd_buy_dlc(Config(), State())
assert any("No game" in str(c) for c in mock_echo.call_args_list)
def test_unblock_fails(self) -> None:
state = State(current_app_id=1, current_game_name="G")
with (
patch(f"{PKG}.unblock_store", return_value=False),
patch(f"{PKG}._echo"),
):
cmd_buy_dlc(Config(), state)
def test_success_reblock(self) -> None:
state = State(current_app_id=1, current_game_name="G")
config = Config(block_store=True)
with (
patch(f"{PKG}.unblock_store", return_value=True),
patch(f"{PKG}.block_store", return_value=True),
patch(f"{PKG}.restart_steam"),
patch(f"{PKG}._echo"),
patch("builtins.input", return_value=""),
):
cmd_buy_dlc(config, state)
def test_reblock_fails(self) -> None:
state = State(current_app_id=1, current_game_name="G")
config = Config(block_store=True)
with (
patch(f"{PKG}.unblock_store", return_value=True),
patch(f"{PKG}.block_store", return_value=False),
patch(f"{PKG}._echo") as mock_echo,
patch("builtins.input", return_value=""),
):
cmd_buy_dlc(config, state)
assert any("Warning" in str(c) for c in mock_echo.call_args_list)
def test_no_reblock(self) -> None:
state = State(current_app_id=1, current_game_name="G")
config = Config(block_store=False)
with (
patch(f"{PKG}.unblock_store", return_value=True),
patch(f"{PKG}._echo"),
patch("builtins.input", return_value=""),
):
cmd_buy_dlc(config, state)
class TestCmdReset:
"""Tests for cmd_reset."""
def test_normal_reset(self) -> None:
state = State(current_app_id=1, current_game_name="G", finished_app_ids=[1])
with (
patch(f"{PKG}.unblock_store"),
patch(f"{PKG}.get_all_owned_app_ids", return_value=[1, 2]),
patch(f"{PKG}.unhide_all_games", return_value=2),
patch(f"{PKG}._echo"),
patch.object(State, "save"),
):
cmd_reset(Config(), state)
assert state.current_app_id is None
assert state.finished_app_ids == []
def test_unhide_fails(self) -> None:
state = State(current_app_id=1)
with (
patch(f"{PKG}.unblock_store"),
patch(
f"{PKG}.get_all_owned_app_ids",
side_effect=OSError("fail"),
),
patch(f"{PKG}._echo"),
patch.object(State, "save"),
):
cmd_reset(Config(), state)
def test_unhide_returns_zero(self) -> None:
state = State(current_app_id=1)
with (
patch(f"{PKG}.unblock_store"),
patch(f"{PKG}.get_all_owned_app_ids", return_value=[1, 2]),
patch(f"{PKG}.unhide_all_games", return_value=0),
patch(f"{PKG}._echo"),
patch.object(State, "save"),
):
cmd_reset(Config(), state)
def test_no_owned_ids(self) -> None:
state = State(current_app_id=1)
with (
patch(f"{PKG}.unblock_store"),
patch(f"{PKG}.get_all_owned_app_ids", return_value=[]),
patch(f"{PKG}._echo"),
patch.object(State, "save"),
):
cmd_reset(Config(), state)
class TestCmdInstalled:
"""Tests for cmd_installed."""
def test_shows_games(self) -> None:
with (
patch(
f"{PKG}.get_installed_games",
return_value=[(440, "TF2"), (228980, "RT")],
),
patch(f"{PKG}.PROTECTED_APP_IDS", {228980}),
patch(f"{PKG}._echo"),
):
cmd_installed(Config(), State(current_app_id=440))
class TestCmdUninstall:
"""Tests for cmd_uninstall."""
def test_no_game(self) -> None:
with patch(f"{PKG}._echo") as mock_echo:
cmd_uninstall(Config(), State())
assert any("No game" in str(c) for c in mock_echo.call_args_list)
def test_nothing_to_remove(self) -> None:
state = State(current_app_id=440)
with (
patch(f"{PKG}.get_installed_games", return_value=[(440, "TF2")]),
patch(f"{PKG}._echo"),
):
cmd_uninstall(Config(), state)
def test_confirms_yes(self) -> None:
state = State(current_app_id=440)
with (
patch(
f"{PKG}.get_installed_games",
return_value=[(440, "TF2"), (730, "CS")],
),
patch(f"{PKG}.uninstall_other_games", return_value=1),
patch("builtins.input", return_value="YES"),
patch(f"{PKG}._echo"),
):
cmd_uninstall(Config(), state)
def test_aborts(self) -> None:
state = State(current_app_id=440)
with (
patch(
f"{PKG}.get_installed_games",
return_value=[(440, "TF2"), (730, "CS")],
),
patch("builtins.input", return_value="no"),
patch(f"{PKG}._echo") as mock_echo,
):
cmd_uninstall(Config(), state)
assert any("Aborted" in str(c) for c in mock_echo.call_args_list)
class TestCmdSetup:
"""Tests for cmd_setup."""
def test_calls_interactive(self) -> None:
with patch(f"{PKG}.interactive_setup") as mock_setup:
cmd_setup(Config(), State())
mock_setup.assert_called_once()
class TestCmdInstall:
"""Tests for cmd_install."""
def test_no_game(self) -> None:
with patch(f"{PKG}._echo") as mock_echo:
cmd_install(Config(), State())
assert any("No game" in str(c) for c in mock_echo.call_args_list)
def test_already_installed(self) -> None:
state = State(current_app_id=1, current_game_name="G")
with (
patch(f"{PKG}.is_game_installed", return_value=True),
patch(f"{PKG}._echo"),
):
cmd_install(Config(), state)
def test_installs_ok(self) -> None:
state = State(current_app_id=1, current_game_name="G")
with (
patch(f"{PKG}.is_game_installed", return_value=False),
patch(f"{PKG}.install_game", return_value=True),
patch(f"{PKG}._echo"),
):
cmd_install(Config(steam_id="i"), state)
def test_install_fails(self) -> None:
state = State(current_app_id=1, current_game_name="G")
with (
patch(f"{PKG}.is_game_installed", return_value=False),
patch(f"{PKG}.install_game", return_value=False),
patch(f"{PKG}._echo"),
):
cmd_install(Config(steam_id="i"), state)
class TestCmdHide:
"""Tests for cmd_hide."""
def test_no_game(self) -> None:
with patch(f"{PKG}._echo"):
cmd_hide(Config(), State())
def test_no_owned(self) -> None:
state = State(current_app_id=1, current_game_name="G")
with (
patch(f"{PKG}.get_all_owned_app_ids", return_value=[]),
patch(f"{PKG}._echo"),
):
cmd_hide(Config(), state)
def test_hides(self) -> None:
state = State(current_app_id=1, current_game_name="G")
with (
patch(f"{PKG}.get_all_owned_app_ids", return_value=[1, 2]),
patch(f"{PKG}.hide_other_games", return_value=1),
patch(f"{PKG}._echo"),
):
cmd_hide(Config(), state)
def test_hides_zero(self) -> None:
state = State(current_app_id=1, current_game_name="G")
with (
patch(f"{PKG}.get_all_owned_app_ids", return_value=[1]),
patch(f"{PKG}.hide_other_games", return_value=0),
patch(f"{PKG}._echo"),
):
cmd_hide(Config(), state)
class TestCmdUnhide:
"""Tests for cmd_unhide."""
def test_no_owned(self) -> None:
with (
patch(f"{PKG}.get_all_owned_app_ids", return_value=[]),
patch(f"{PKG}._echo"),
):
cmd_unhide(Config(), State())
def test_unhides(self) -> None:
with (
patch(f"{PKG}.get_all_owned_app_ids", return_value=[1]),
patch(f"{PKG}.unhide_all_games", return_value=1),
patch(f"{PKG}._echo"),
):
cmd_unhide(Config(), State())
def test_unhides_zero(self) -> None:
with (
patch(f"{PKG}.get_all_owned_app_ids", return_value=[1]),
patch(f"{PKG}.unhide_all_games", return_value=0),
patch(f"{PKG}._echo"),
):
cmd_unhide(Config(), State())
class TestTryReassignShorterGame:
"""Tests for _try_reassign_shorter_game."""
def test_no_snapshot(self) -> None:
with patch(f"{PKG}.load_snapshot", return_value=None):
assert not _try_reassign_shorter_game({}, 1, 10.0, State(), Config())
def test_no_shorter_candidate(self) -> None:
snap = [_snap(1, "G", 10, 5, 10.0), _snap(2, "H", 10, 5, -1)]
with (
patch(f"{PKG}.load_snapshot", return_value=snap),
patch(f"{PKG}._echo"),
):
result = _try_reassign_shorter_game(
{1: 10.0},
1,
10.0,
State(),
Config(),
)
assert not result
def test_reassigns(self) -> None:
snap = [
_snap(1, "Long", 10, 5, 100.0),
_snap(2, "Short", 10, 5, 5.0),
]
state = State(current_app_id=1, current_game_name="Long")
short_game = GameInfo(
app_id=2,
name="Short",
total_achievements=10,
unlocked_achievements=5,
playtime_minutes=60,
completionist_hours=5.0,
)
with (
patch(f"{PKG}.load_snapshot", return_value=snap),
patch(f"{PKG}._echo"),
patch(
f"{PKG}._pick_playable_candidate",
return_value=short_game,
),
patch(f"{PKG}.pick_next_game"),
):
result = _try_reassign_shorter_game(
{1: 100.0, 2: 5.0},
1,
100.0,
state,
Config(),
)
assert result
def test_playable_none(self) -> None:
snap = [
_snap(1, "Long", 10, 5, 100.0),
_snap(2, "Short", 10, 5, 5.0),
]
with (
patch(f"{PKG}.load_snapshot", return_value=snap),
patch(f"{PKG}._pick_playable_candidate", return_value=None),
patch(f"{PKG}._echo"),
):
result = _try_reassign_shorter_game(
{1: 100.0, 2: 5.0},
1,
100.0,
State(),
Config(),
)
assert not result
def test_playable_longer(self) -> None:
"""Playable candidate is longer than current — no reassign."""
snap = [
_snap(1, "Short", 10, 5, 10.0),
_snap(2, "Long", 10, 5, 200.0),
]
long_game = GameInfo(
app_id=2,
name="Long",
total_achievements=10,
unlocked_achievements=5,
playtime_minutes=60,
completionist_hours=200.0,
)
with (
patch(f"{PKG}.load_snapshot", return_value=snap),
patch(f"{PKG}._pick_playable_candidate", return_value=long_game),
patch(f"{PKG}._echo"),
):
result = _try_reassign_shorter_game(
{1: 10.0, 2: 200.0},
1,
10.0,
State(),
Config(),
)
assert not result

View File

@ -0,0 +1,376 @@
"""Tests for main CLI module — part 2 (missing coverage)."""
from __future__ import annotations
import sys
from typing import Any
from unittest.mock import MagicMock, patch
import pytest
from python_pkg.steam_backlog_enforcer.config import Config, State
from python_pkg.steam_backlog_enforcer.main import (
_enforce_on_done,
_finalize_completion,
cmd_done,
main,
)
from python_pkg.steam_backlog_enforcer.steam_api import GameInfo
PKG = "python_pkg.steam_backlog_enforcer.main"
def _snap(
app_id: int = 1,
name: str = "G",
total: int = 10,
unlocked: int = 0,
hours: float = -1,
) -> dict[str, Any]:
return {
"app_id": app_id,
"name": name,
"total_achievements": total,
"unlocked_achievements": unlocked,
"playtime_minutes": 60,
"completionist_hours": hours,
}
class TestFinalizeCompletion:
"""Tests for _finalize_completion."""
def test_with_snapshot_and_hiding(self) -> None:
config = Config(steam_api_key="k", steam_id="i")
state = State(current_app_id=1, current_game_name="G")
snap = [_snap(2, "NewGame", 10, 0, 5.0)]
with (
patch(f"{PKG}._echo"),
patch(f"{PKG}.load_snapshot", return_value=snap),
patch(f"{PKG}.pick_next_game") as mock_pick,
patch(f"{PKG}.get_all_owned_app_ids", return_value=[1, 2, 3]),
patch(f"{PKG}.hide_other_games", return_value=2),
patch(f"{PKG}.send_notification"),
patch.object(State, "save"),
):
def set_next(
games: object,
s: State,
c: object,
) -> None:
s.current_app_id = 2
s.current_game_name = "NewGame"
mock_pick.side_effect = set_next
_finalize_completion(config, state, "G", 1)
assert 1 in state.finished_app_ids
def test_no_snapshot(self) -> None:
config = Config()
state = State(current_app_id=1, current_game_name="G")
with (
patch(f"{PKG}._echo"),
patch(f"{PKG}.load_snapshot", return_value=None),
patch.object(State, "save"),
):
_finalize_completion(config, state, "G", 1)
assert state.current_app_id is None
def test_no_next_game(self) -> None:
config = Config()
state = State(current_app_id=1, current_game_name="G")
snap = [_snap(1, "G", 10, 10)]
with (
patch(f"{PKG}._echo"),
patch(f"{PKG}.load_snapshot", return_value=snap),
patch(f"{PKG}.pick_next_game") as mock_pick,
patch.object(State, "save"),
):
def set_none(
games: object,
s: State,
c: object,
) -> None:
s.current_app_id = None
mock_pick.side_effect = set_none
_finalize_completion(config, state, "G", 1)
def test_no_owned_ids(self) -> None:
config = Config()
state = State(current_app_id=1, current_game_name="G")
snap = [_snap(2, "Next", 10, 0)]
with (
patch(f"{PKG}._echo"),
patch(f"{PKG}.load_snapshot", return_value=snap),
patch(f"{PKG}.pick_next_game") as mock_pick,
patch(f"{PKG}.get_all_owned_app_ids", return_value=[]),
patch(f"{PKG}.send_notification"),
patch.object(State, "save"),
):
def set_2(
games: object,
s: State,
c: object,
) -> None:
s.current_app_id = 2
s.current_game_name = "Next"
mock_pick.side_effect = set_2
_finalize_completion(config, state, "G", 1)
def test_hide_returns_zero(self) -> None:
config = Config()
state = State(current_app_id=1, current_game_name="G")
snap = [_snap(2, "Next", 10, 0)]
with (
patch(f"{PKG}._echo"),
patch(f"{PKG}.load_snapshot", return_value=snap),
patch(f"{PKG}.pick_next_game") as mock_pick,
patch(f"{PKG}.get_all_owned_app_ids", return_value=[1, 2]),
patch(f"{PKG}.hide_other_games", return_value=0),
patch(f"{PKG}.send_notification"),
patch.object(State, "save"),
):
def set_2(
games: object,
s: State,
c: object,
) -> None:
s.current_app_id = 2
s.current_game_name = "Next"
mock_pick.side_effect = set_2
_finalize_completion(config, state, "G", 1)
class TestEnforceOnDone:
"""Tests for _enforce_on_done."""
def test_no_current_game(self) -> None:
_enforce_on_done(Config(), State())
def test_kills_and_uninstalls(self) -> None:
config = Config(
kill_unauthorized_games=True,
uninstall_other_games=True,
)
state = State(current_app_id=1, current_game_name="G")
with (
patch(f"{PKG}._echo"),
patch(
f"{PKG}.enforce_allowed_game",
return_value=[(1234, 999)],
),
patch(f"{PKG}.uninstall_other_games", return_value=2),
patch(f"{PKG}.is_game_installed", return_value=True),
):
_enforce_on_done(config, state)
def test_no_violations_no_uninstalls(self) -> None:
config = Config(
kill_unauthorized_games=True,
uninstall_other_games=True,
)
state = State(current_app_id=1, current_game_name="G")
with (
patch(f"{PKG}._echo"),
patch(f"{PKG}.enforce_allowed_game", return_value=[]),
patch(f"{PKG}.uninstall_other_games", return_value=0),
patch(f"{PKG}.is_game_installed", return_value=True),
):
_enforce_on_done(config, state)
def test_reinstall_when_not_installed(self) -> None:
config = Config(
kill_unauthorized_games=False,
uninstall_other_games=False,
steam_id="s1",
)
state = State(current_app_id=1, current_game_name="G")
with (
patch(f"{PKG}._echo"),
patch(f"{PKG}.is_game_installed", return_value=False),
patch(f"{PKG}.install_game") as mock_install,
):
_enforce_on_done(config, state)
mock_install.assert_called_once_with(1, "G", "s1", use_steam_protocol=True)
class TestCmdDone:
"""Tests for cmd_done."""
def test_no_game_assigned(self) -> None:
with patch(f"{PKG}._echo") as mock_echo:
cmd_done(Config(), State())
assert any("No game" in str(c) for c in mock_echo.call_args_list)
def test_fetch_fails(self) -> None:
mock_client = MagicMock()
mock_client.refresh_single_game.return_value = None
state = State(current_app_id=1, current_game_name="G")
with (
patch(f"{PKG}.SteamAPIClient", return_value=mock_client),
patch(f"{PKG}._echo"),
):
cmd_done(Config(steam_api_key="k", steam_id="i"), state)
def test_not_complete_enforces(self) -> None:
game = GameInfo(
app_id=1,
name="G",
total_achievements=10,
unlocked_achievements=5,
playtime_minutes=60,
)
mock_client = MagicMock()
mock_client.refresh_single_game.return_value = game
state = State(current_app_id=1, current_game_name="G")
with (
patch(f"{PKG}.SteamAPIClient", return_value=mock_client),
patch(f"{PKG}._echo"),
patch(f"{PKG}.load_hltb_cache", return_value={1: 20.0}),
patch(f"{PKG}._try_reassign_shorter_game", return_value=False),
patch(f"{PKG}._enforce_on_done"),
):
cmd_done(Config(steam_api_key="k", steam_id="i"), state)
def test_complete_finalizes(self) -> None:
game = GameInfo(
app_id=1,
name="G",
total_achievements=10,
unlocked_achievements=10,
playtime_minutes=60,
)
mock_client = MagicMock()
mock_client.refresh_single_game.return_value = game
state = State(current_app_id=1, current_game_name="G")
with (
patch(f"{PKG}.SteamAPIClient", return_value=mock_client),
patch(f"{PKG}._echo"),
patch(f"{PKG}.load_hltb_cache", return_value={1: 10.0}),
patch(f"{PKG}._try_reassign_shorter_game", return_value=False),
patch(f"{PKG}._finalize_completion") as mock_final,
):
cmd_done(Config(steam_api_key="k", steam_id="i"), state)
mock_final.assert_called_once()
def test_hltb_cache_miss_fetches(self) -> None:
game = GameInfo(
app_id=1,
name="G",
total_achievements=10,
unlocked_achievements=5,
playtime_minutes=60,
)
mock_client = MagicMock()
mock_client.refresh_single_game.return_value = game
state = State(current_app_id=1, current_game_name="G")
with (
patch(f"{PKG}.SteamAPIClient", return_value=mock_client),
patch(f"{PKG}._echo"),
patch(f"{PKG}.load_hltb_cache", return_value={}),
patch(
f"{PKG}.fetch_hltb_times_cached",
return_value={1: 15.0},
),
patch(f"{PKG}._try_reassign_shorter_game", return_value=False),
patch(f"{PKG}._enforce_on_done"),
):
cmd_done(Config(steam_api_key="k", steam_id="i"), state)
def test_hltb_negative_no_display(self) -> None:
"""Covers the hours <= 0 branch (no HLTB estimate display)."""
game = GameInfo(
app_id=1,
name="G",
total_achievements=10,
unlocked_achievements=5,
playtime_minutes=60,
)
mock_client = MagicMock()
mock_client.refresh_single_game.return_value = game
state = State(current_app_id=1, current_game_name="G")
with (
patch(f"{PKG}.SteamAPIClient", return_value=mock_client),
patch(f"{PKG}._echo"),
patch(f"{PKG}.load_hltb_cache", return_value={1: -1.0}),
patch(f"{PKG}._try_reassign_shorter_game", return_value=False),
patch(f"{PKG}._enforce_on_done"),
):
cmd_done(Config(steam_api_key="k", steam_id="i"), state)
def test_reassign_returns_true(self) -> None:
game = GameInfo(
app_id=1,
name="G",
total_achievements=10,
unlocked_achievements=5,
playtime_minutes=60,
)
mock_client = MagicMock()
mock_client.refresh_single_game.return_value = game
state = State(current_app_id=1, current_game_name="G")
with (
patch(f"{PKG}.SteamAPIClient", return_value=mock_client),
patch(f"{PKG}._echo"),
patch(f"{PKG}.load_hltb_cache", return_value={1: 50.0}),
patch(f"{PKG}._try_reassign_shorter_game", return_value=True),
):
cmd_done(Config(steam_api_key="k", steam_id="i"), state)
class TestMain:
"""Tests for main CLI entry point."""
def test_no_args_exits(self) -> None:
with (
patch.object(sys, "argv", ["prog"]),
patch(f"{PKG}._echo"),
pytest.raises(SystemExit, match="1"),
):
main()
def test_unknown_command_exits(self) -> None:
with (
patch.object(sys, "argv", ["prog", "bogus"]),
patch(f"{PKG}._echo"),
pytest.raises(SystemExit, match="1"),
):
main()
def test_valid_command_runs(self) -> None:
mock_cmd = MagicMock()
with (
patch.object(sys, "argv", ["prog", "status"]),
patch(f"{PKG}.Config.load", return_value=Config(steam_api_key="k")),
patch(f"{PKG}.State.load", return_value=State()),
patch.dict(f"{PKG}.COMMANDS", {"status": ("s", mock_cmd)}),
):
main()
mock_cmd.assert_called_once()
def test_setup_no_key_required(self) -> None:
mock_cmd = MagicMock()
with (
patch.object(sys, "argv", ["prog", "setup"]),
patch(f"{PKG}.Config.load", return_value=Config()),
patch(f"{PKG}.State.load", return_value=State()),
patch.dict(f"{PKG}.COMMANDS", {"setup": ("s", mock_cmd)}),
):
main()
mock_cmd.assert_called_once()
def test_no_api_key_exits(self) -> None:
with (
patch.object(sys, "argv", ["prog", "status"]),
patch(f"{PKG}.Config.load", return_value=Config()),
patch(f"{PKG}._echo"),
pytest.raises(SystemExit, match="1"),
):
main()

View File

@ -0,0 +1,248 @@
"""Tests for protondb module."""
from __future__ import annotations
import asyncio
import json
from typing import TYPE_CHECKING, Any
from unittest.mock import AsyncMock, MagicMock, patch
import aiohttp
from python_pkg.steam_backlog_enforcer.protondb import (
HTTP_NOT_FOUND,
ProtonDBRating,
_fetch_batch,
_fetch_one,
_load_cache,
_rating_from_cache,
_rating_to_dict,
_save_cache,
fetch_protondb_ratings,
)
if TYPE_CHECKING:
from pathlib import Path
class TestProtonDBRating:
"""Tests for ProtonDBRating."""
def test_playable_native(self) -> None:
r = ProtonDBRating(app_id=1, tier="native")
assert r.is_playable is True
def test_playable_platinum(self) -> None:
r = ProtonDBRating(app_id=1, tier="platinum")
assert r.is_playable is True
def test_playable_gold(self) -> None:
r = ProtonDBRating(app_id=1, tier="gold")
assert r.is_playable is True
def test_not_playable_silver(self) -> None:
r = ProtonDBRating(app_id=1, tier="silver")
assert r.is_playable is False
def test_not_playable_bronze(self) -> None:
r = ProtonDBRating(app_id=1, tier="bronze")
assert r.is_playable is False
def test_not_playable_borked(self) -> None:
r = ProtonDBRating(app_id=1, tier="borked")
assert r.is_playable is False
def test_playable_no_data(self) -> None:
r = ProtonDBRating(app_id=1, tier="")
assert r.is_playable is True
def test_playable_pending(self) -> None:
r = ProtonDBRating(app_id=1, tier="pending")
assert r.is_playable is True
def test_gold_trending_silver(self) -> None:
r = ProtonDBRating(app_id=1, tier="gold", trending_tier="silver")
assert r.is_playable is False
def test_gold_trending_gold(self) -> None:
r = ProtonDBRating(app_id=1, tier="gold", trending_tier="gold")
assert r.is_playable is True
def test_gold_no_trending(self) -> None:
r = ProtonDBRating(app_id=1, tier="gold", trending_tier="")
assert r.is_playable is True
def test_gold_trending_platinum(self) -> None:
r = ProtonDBRating(app_id=1, tier="gold", trending_tier="platinum")
assert r.is_playable is True
def test_gold_trending_unknown(self) -> None:
r = ProtonDBRating(app_id=1, tier="gold", trending_tier="unknown")
assert r.is_playable is False
def test_unknown_tier(self) -> None:
r = ProtonDBRating(app_id=1, tier="unknown_tier")
assert r.is_playable is False
class TestProtonDBCache:
"""Tests for cache I/O."""
def test_load_cache_exists(self, tmp_path: Path) -> None:
cache_file = tmp_path / "protondb_cache.json"
cache_file.write_text(json.dumps({"440": {"tier": "gold"}}), encoding="utf-8")
with patch(
"python_pkg.steam_backlog_enforcer.protondb.PROTONDB_CACHE_FILE",
cache_file,
):
result = _load_cache()
assert result == {"440": {"tier": "gold"}}
def test_load_cache_missing(self, tmp_path: Path) -> None:
cache_file = tmp_path / "nonexistent.json"
with patch(
"python_pkg.steam_backlog_enforcer.protondb.PROTONDB_CACHE_FILE",
cache_file,
):
assert _load_cache() == {}
def test_save_cache(self, tmp_path: Path) -> None:
cache_file = tmp_path / "protondb_cache.json"
config_dir = tmp_path
with (
patch(
"python_pkg.steam_backlog_enforcer.protondb.PROTONDB_CACHE_FILE",
cache_file,
),
patch("python_pkg.steam_backlog_enforcer.protondb.CONFIG_DIR", config_dir),
):
_save_cache({"440": {"tier": "gold"}})
assert cache_file.exists()
class TestRatingConversion:
"""Tests for rating serialization."""
def test_to_dict(self) -> None:
r = ProtonDBRating(
app_id=1,
tier="gold",
trending_tier="platinum",
score=0.9,
confidence="high",
total_reports=100,
)
d = _rating_to_dict(r)
assert d["tier"] == "gold"
assert d["total_reports"] == 100
def test_from_cache(self) -> None:
data: dict[str, Any] = {
"tier": "silver",
"trending_tier": "bronze",
"score": 0.5,
}
r = _rating_from_cache(440, data)
assert r.app_id == 440
assert r.tier == "silver"
assert r.trending_tier == "bronze"
def test_from_cache_defaults(self) -> None:
r = _rating_from_cache(440, {})
assert r.tier == ""
assert r.total_reports == 0
class TestFetchOne:
"""Tests for _fetch_one."""
def test_success(self) -> None:
mock_resp = AsyncMock()
mock_resp.status = 200
mock_resp.raise_for_status = MagicMock()
mock_resp.json = AsyncMock(
return_value={"tier": "gold", "trendingTier": "platinum"}
)
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
mock_resp.__aexit__ = AsyncMock(return_value=False)
mock_session = MagicMock()
mock_session.get = MagicMock(return_value=mock_resp)
sem = asyncio.Semaphore(1)
result = asyncio.run(_fetch_one(mock_session, sem, 440))
assert result.tier == "gold"
def test_not_found(self) -> None:
mock_resp = AsyncMock()
mock_resp.status = HTTP_NOT_FOUND
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
mock_resp.__aexit__ = AsyncMock(return_value=False)
mock_session = MagicMock()
mock_session.get = MagicMock(return_value=mock_resp)
sem = asyncio.Semaphore(1)
result = asyncio.run(_fetch_one(mock_session, sem, 440))
assert result.tier == ""
def test_client_error(self) -> None:
mock_resp = AsyncMock()
mock_resp.status = 200
mock_resp.raise_for_status = MagicMock(side_effect=aiohttp.ClientError)
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
mock_resp.__aexit__ = AsyncMock(return_value=False)
mock_session = MagicMock()
mock_session.get = MagicMock(return_value=mock_resp)
sem = asyncio.Semaphore(1)
result = asyncio.run(_fetch_one(mock_session, sem, 440))
assert result.tier == ""
class TestFetchBatch:
"""Tests for _fetch_batch."""
def test_returns_ratings(self) -> None:
rating = ProtonDBRating(app_id=440, tier="gold")
with patch(
"python_pkg.steam_backlog_enforcer.protondb._fetch_one",
new_callable=AsyncMock,
return_value=rating,
):
result = asyncio.run(_fetch_batch([440]))
assert len(result) == 1
assert result[0].tier == "gold"
class TestFetchProtondbRatings:
"""Tests for fetch_protondb_ratings."""
def test_all_cached(self, tmp_path: Path) -> None:
cache_file = tmp_path / "protondb_cache.json"
cache_file.write_text(json.dumps({"440": {"tier": "gold"}}), encoding="utf-8")
with patch(
"python_pkg.steam_backlog_enforcer.protondb.PROTONDB_CACHE_FILE",
cache_file,
):
result = fetch_protondb_ratings([440])
assert 440 in result
assert result[440].tier == "gold"
def test_fetch_uncached(self, tmp_path: Path) -> None:
cache_file = tmp_path / "protondb_cache.json"
config_dir = tmp_path
with (
patch(
"python_pkg.steam_backlog_enforcer.protondb.PROTONDB_CACHE_FILE",
cache_file,
),
patch("python_pkg.steam_backlog_enforcer.protondb.CONFIG_DIR", config_dir),
patch(
"python_pkg.steam_backlog_enforcer.protondb._fetch_batch",
return_value=[ProtonDBRating(app_id=440, tier="platinum")],
),
):
result = fetch_protondb_ratings([440])
assert result[440].tier == "platinum"

View File

@ -0,0 +1,439 @@
"""Tests for scanning module."""
from __future__ import annotations
from typing import Any
from unittest.mock import MagicMock, patch
from python_pkg.steam_backlog_enforcer.config import Config, State
from python_pkg.steam_backlog_enforcer.protondb import ProtonDBRating
from python_pkg.steam_backlog_enforcer.scanning import (
_pick_playable_candidate,
do_check,
do_scan,
pick_next_game,
)
from python_pkg.steam_backlog_enforcer.steam_api import GameInfo
def _game(
app_id: int = 1,
name: str = "G",
total: int = 10,
unlocked: int = 0,
hours: float = -1,
) -> GameInfo:
return GameInfo(
app_id=app_id,
name=name,
total_achievements=total,
unlocked_achievements=unlocked,
playtime_minutes=60,
completionist_hours=hours,
)
class TestDoScan:
"""Tests for do_scan."""
def test_scans_and_picks(self) -> None:
game = _game(app_id=440, name="TF2", total=10, unlocked=5)
mock_client = MagicMock()
def build_game_list(
skip_app_ids: Any = None,
progress_callback: Any = None,
) -> list[GameInfo]:
# Trigger progress callback to cover those lines.
if progress_callback:
progress_callback(50, 100)
progress_callback(100, 100)
return [game]
mock_client.build_game_list.side_effect = build_game_list
with (
patch(
"python_pkg.steam_backlog_enforcer.scanning.SteamAPIClient",
return_value=mock_client,
),
patch(
"python_pkg.steam_backlog_enforcer.scanning.fetch_hltb_times_cached",
side_effect=lambda games, progress_cb=None: (
progress_cb(1, 1, 1, "TF2") if progress_cb else None,
{440: 20.0},
)[1],
),
patch(
"python_pkg.steam_backlog_enforcer.scanning.save_snapshot",
),
patch(
"python_pkg.steam_backlog_enforcer.scanning.pick_next_game",
) as mock_pick,
patch(
"python_pkg.steam_backlog_enforcer.scanning._echo",
),
):
config = Config(steam_api_key="k", steam_id="i")
state = State()
result = do_scan(config, state)
assert len(result) == 1
mock_pick.assert_called_once()
def test_scan_all_complete(self) -> None:
game = _game(app_id=440, name="TF2", total=10, unlocked=10)
mock_client = MagicMock()
def build_game_list(
skip_app_ids: Any = None,
progress_callback: Any = None,
) -> list[GameInfo]:
if progress_callback:
# current=1, total=2 → not %50 and not ==total → covers False branch
progress_callback(1, 2)
return [game]
mock_client.build_game_list.side_effect = build_game_list
with (
patch(
"python_pkg.steam_backlog_enforcer.scanning.SteamAPIClient",
return_value=mock_client,
),
patch(
"python_pkg.steam_backlog_enforcer.scanning.save_snapshot",
),
patch(
"python_pkg.steam_backlog_enforcer.scanning.pick_next_game",
) as mock_pick,
patch("python_pkg.steam_backlog_enforcer.scanning._echo"),
):
config = Config(steam_api_key="k", steam_id="i")
state = State()
result = do_scan(config, state)
assert len(result) == 1
mock_pick.assert_called_once()
def test_scan_already_assigned(self) -> None:
game = _game(app_id=440, total=10, unlocked=5)
mock_client = MagicMock()
mock_client.build_game_list.return_value = [game]
with (
patch(
"python_pkg.steam_backlog_enforcer.scanning.SteamAPIClient",
return_value=mock_client,
),
patch(
"python_pkg.steam_backlog_enforcer.scanning.fetch_hltb_times_cached",
return_value={440: 20.0},
),
patch(
"python_pkg.steam_backlog_enforcer.scanning.save_snapshot",
),
patch(
"python_pkg.steam_backlog_enforcer.scanning.pick_next_game",
) as mock_pick,
patch("python_pkg.steam_backlog_enforcer.scanning._echo"),
):
config = Config(steam_api_key="k", steam_id="i")
state = State(current_app_id=440)
result = do_scan(config, state)
assert len(result) == 1
mock_pick.assert_not_called()
class TestPickPlayableCandidate:
"""Tests for _pick_playable_candidate."""
def test_finds_playable(self) -> None:
game = _game(app_id=440, name="TF2")
with (
patch(
"python_pkg.steam_backlog_enforcer.scanning.fetch_protondb_ratings",
return_value={
440: ProtonDBRating(app_id=440, tier="gold"),
},
),
patch("python_pkg.steam_backlog_enforcer.scanning._echo"),
):
result = _pick_playable_candidate([game])
assert result is not None
assert result.app_id == 440
def test_skips_bad_rating(self) -> None:
bad = _game(app_id=1, name="Bad")
good = _game(app_id=2, name="Good")
with (
patch(
"python_pkg.steam_backlog_enforcer.scanning.fetch_protondb_ratings",
return_value={
1: ProtonDBRating(app_id=1, tier="borked"),
2: ProtonDBRating(app_id=2, tier="platinum"),
},
),
patch("python_pkg.steam_backlog_enforcer.scanning._echo"),
):
result = _pick_playable_candidate([bad, good])
assert result is not None
assert result.app_id == 2
def test_all_unplayable(self) -> None:
game = _game(app_id=1, name="Bad")
with (
patch(
"python_pkg.steam_backlog_enforcer.scanning.fetch_protondb_ratings",
return_value={
1: ProtonDBRating(app_id=1, tier="borked"),
},
),
patch("python_pkg.steam_backlog_enforcer.scanning._echo"),
):
assert _pick_playable_candidate([game]) is None
def test_empty_list(self) -> None:
assert _pick_playable_candidate([]) is None
def test_first_in_batch_playable(self) -> None:
"""First game in first batch is playable — no skip message."""
game = _game(app_id=440, name="TF2")
with (
patch(
"python_pkg.steam_backlog_enforcer.scanning.fetch_protondb_ratings",
return_value={
440: ProtonDBRating(app_id=440, tier="platinum"),
},
),
patch("python_pkg.steam_backlog_enforcer.scanning._echo"),
):
result = _pick_playable_candidate([game])
assert result is not None
class TestPickNextGame:
"""Tests for pick_next_game."""
def test_picks_shortest(self) -> None:
g1 = _game(app_id=1, name="Long", hours=100.0)
g2 = _game(app_id=2, name="Short", hours=10.0)
config = Config(steam_api_key="k", steam_id="i")
state = State()
with (
patch(
"python_pkg.steam_backlog_enforcer.scanning._pick_playable_candidate",
side_effect=lambda c: c[0] if c else None,
),
patch("python_pkg.steam_backlog_enforcer.scanning._echo"),
patch(
"python_pkg.steam_backlog_enforcer.scanning.is_game_installed",
return_value=True,
),
):
pick_next_game([g1, g2], state, config)
assert state.current_app_id == 2
def test_no_candidates(self) -> None:
g1 = _game(app_id=1, total=5, unlocked=5)
config = Config(steam_api_key="k", steam_id="i")
state = State()
with patch("python_pkg.steam_backlog_enforcer.scanning._echo"):
pick_next_game([g1], state, config)
assert state.current_app_id is None
def test_skips_finished(self) -> None:
g1 = _game(app_id=1, name="G1", hours=10.0)
g2 = _game(app_id=2, name="G2", hours=20.0)
config = Config(steam_api_key="k", steam_id="i")
state = State(finished_app_ids=[1])
with (
patch(
"python_pkg.steam_backlog_enforcer.scanning._pick_playable_candidate",
side_effect=lambda c: c[0] if c else None,
),
patch("python_pkg.steam_backlog_enforcer.scanning._echo"),
patch(
"python_pkg.steam_backlog_enforcer.scanning.is_game_installed",
return_value=True,
),
):
pick_next_game([g1, g2], state, config)
assert state.current_app_id == 2
def test_no_playable(self) -> None:
g1 = _game(app_id=1, name="G1")
config = Config(steam_api_key="k", steam_id="i")
state = State()
with (
patch(
"python_pkg.steam_backlog_enforcer.scanning._pick_playable_candidate",
return_value=None,
),
patch("python_pkg.steam_backlog_enforcer.scanning._echo"),
):
pick_next_game([g1], state, config)
assert state.current_app_id is None
def test_uninstalls_others(self) -> None:
g1 = _game(app_id=1, name="G1", hours=10.0)
config = Config(steam_api_key="k", steam_id="i", uninstall_other_games=True)
state = State()
with (
patch(
"python_pkg.steam_backlog_enforcer.scanning._pick_playable_candidate",
side_effect=lambda c: c[0] if c else None,
),
patch("python_pkg.steam_backlog_enforcer.scanning._echo"),
patch(
"python_pkg.steam_backlog_enforcer.scanning.uninstall_other_games",
return_value=2,
),
patch(
"python_pkg.steam_backlog_enforcer.scanning.is_game_installed",
return_value=True,
),
):
pick_next_game([g1], state, config)
assert state.current_app_id == 1
def test_auto_installs(self) -> None:
g1 = _game(app_id=1, name="G1", hours=10.0)
config = Config(steam_api_key="k", steam_id="i", uninstall_other_games=False)
state = State()
with (
patch(
"python_pkg.steam_backlog_enforcer.scanning._pick_playable_candidate",
side_effect=lambda c: c[0] if c else None,
),
patch("python_pkg.steam_backlog_enforcer.scanning._echo"),
patch(
"python_pkg.steam_backlog_enforcer.scanning.is_game_installed",
return_value=False,
),
patch(
"python_pkg.steam_backlog_enforcer.scanning.install_game"
) as mock_install,
):
pick_next_game([g1], state, config)
mock_install.assert_called_once()
def test_unknown_hours(self) -> None:
g1 = _game(app_id=1, name="G1", hours=-1)
g2 = _game(app_id=2, name="G2", hours=10.0)
config = Config(steam_api_key="k", steam_id="i")
state = State()
with (
patch(
"python_pkg.steam_backlog_enforcer.scanning._pick_playable_candidate",
side_effect=lambda c: c[0] if c else None,
),
patch("python_pkg.steam_backlog_enforcer.scanning._echo"),
patch(
"python_pkg.steam_backlog_enforcer.scanning.is_game_installed",
return_value=True,
),
):
pick_next_game([g1, g2], state, config)
assert state.current_app_id == 2
def test_picks_game_no_hours(self) -> None:
"""Chosen game has no HLTB hours — covers no-hours output branch."""
g1 = _game(app_id=1, name="G1", hours=-1)
config = Config(steam_api_key="k", steam_id="i")
state = State()
with (
patch(
"python_pkg.steam_backlog_enforcer.scanning._pick_playable_candidate",
side_effect=lambda c: c[0] if c else None,
),
patch("python_pkg.steam_backlog_enforcer.scanning._echo"),
patch(
"python_pkg.steam_backlog_enforcer.scanning.is_game_installed",
return_value=True,
),
):
pick_next_game([g1], state, config)
assert state.current_app_id == 1
class TestDoCheck:
"""Tests for do_check."""
def test_no_assignment(self) -> None:
with patch("python_pkg.steam_backlog_enforcer.scanning._echo") as mock_echo:
do_check(Config(), State())
mock_echo.assert_called()
def test_fetch_fails(self) -> None:
mock_client = MagicMock()
mock_client.refresh_single_game.return_value = None
with (
patch(
"python_pkg.steam_backlog_enforcer.scanning.SteamAPIClient",
return_value=mock_client,
),
patch("python_pkg.steam_backlog_enforcer.scanning._echo"),
patch("python_pkg.steam_backlog_enforcer.scanning.detect_tampering"),
):
state = State(current_app_id=440, current_game_name="TF2")
do_check(Config(steam_api_key="k", steam_id="i"), state)
def test_complete(self) -> None:
game = _game(app_id=440, name="TF2", total=5, unlocked=5)
mock_client = MagicMock()
mock_client.refresh_single_game.return_value = game
snap = [game.to_snapshot()]
with (
patch(
"python_pkg.steam_backlog_enforcer.scanning.SteamAPIClient",
return_value=mock_client,
),
patch("python_pkg.steam_backlog_enforcer.scanning._echo"),
patch(
"python_pkg.steam_backlog_enforcer.scanning.send_notification",
),
patch(
"python_pkg.steam_backlog_enforcer.scanning.load_snapshot",
return_value=snap,
),
patch(
"python_pkg.steam_backlog_enforcer.scanning.pick_next_game",
),
patch("python_pkg.steam_backlog_enforcer.scanning.detect_tampering"),
):
state = State(current_app_id=440, current_game_name="TF2")
do_check(Config(steam_api_key="k", steam_id="i"), state)
assert 440 in state.finished_app_ids
def test_complete_no_snapshot(self) -> None:
game = _game(app_id=440, name="TF2", total=5, unlocked=5)
mock_client = MagicMock()
mock_client.refresh_single_game.return_value = game
with (
patch(
"python_pkg.steam_backlog_enforcer.scanning.SteamAPIClient",
return_value=mock_client,
),
patch("python_pkg.steam_backlog_enforcer.scanning._echo"),
patch(
"python_pkg.steam_backlog_enforcer.scanning.send_notification",
),
patch(
"python_pkg.steam_backlog_enforcer.scanning.load_snapshot",
return_value=None,
),
patch("python_pkg.steam_backlog_enforcer.scanning.detect_tampering"),
):
state = State(current_app_id=440, current_game_name="TF2")
do_check(Config(steam_api_key="k", steam_id="i"), state)
def test_not_complete(self) -> None:
game = _game(app_id=440, name="TF2", total=10, unlocked=5)
mock_client = MagicMock()
mock_client.refresh_single_game.return_value = game
with (
patch(
"python_pkg.steam_backlog_enforcer.scanning.SteamAPIClient",
return_value=mock_client,
),
patch("python_pkg.steam_backlog_enforcer.scanning._echo"),
patch("python_pkg.steam_backlog_enforcer.scanning.detect_tampering"),
):
state = State(current_app_id=440, current_game_name="TF2")
do_check(Config(steam_api_key="k", steam_id="i"), state)

View File

@ -0,0 +1,134 @@
"""Tests for scanning module — part 2 (missing coverage)."""
from __future__ import annotations
from typing import Any
from unittest.mock import MagicMock, patch
from python_pkg.steam_backlog_enforcer.config import Config, State
from python_pkg.steam_backlog_enforcer.scanning import (
_check_game_tampering,
detect_tampering,
)
PKG = "python_pkg.steam_backlog_enforcer.scanning"
def _entry(
app_id: int = 1,
name: str = "G",
total: int = 10,
unlocked: int = 5,
playtime: int = 60,
) -> dict[str, Any]:
return {
"app_id": app_id,
"name": name,
"total_achievements": total,
"unlocked_achievements": unlocked,
"playtime_minutes": playtime,
}
class TestCheckGameTampering:
"""Tests for _check_game_tampering."""
def test_current_game_skipped(self) -> None:
state = State(current_app_id=1)
result = _check_game_tampering(MagicMock(), _entry(app_id=1), state)
assert result is None
def test_already_complete_skipped(self) -> None:
state = State()
result = _check_game_tampering(
MagicMock(),
_entry(unlocked=10, total=10),
state,
)
assert result is None
def test_zero_playtime_skipped(self) -> None:
state = State()
result = _check_game_tampering(
MagicMock(),
_entry(playtime=0),
state,
)
assert result is None
def test_no_new_achievements(self) -> None:
client = MagicMock()
game = MagicMock()
game.unlocked_achievements = 5
client.refresh_single_game.return_value = game
state = State()
result = _check_game_tampering(client, _entry(unlocked=5), state)
assert result is None
def test_tampering_detected(self) -> None:
client = MagicMock()
game = MagicMock()
game.unlocked_achievements = 8
client.refresh_single_game.return_value = game
state = State()
entry = _entry(app_id=99, name="Cheated", unlocked=5)
result = _check_game_tampering(client, entry, state)
assert result is not None
assert result == ("Cheated", 99, 3)
def test_refresh_returns_none(self) -> None:
client = MagicMock()
client.refresh_single_game.return_value = None
state = State()
result = _check_game_tampering(client, _entry(), state)
assert result is None
class TestDetectTampering:
"""Tests for detect_tampering."""
def test_no_snapshot(self) -> None:
with patch(f"{PKG}.load_snapshot", return_value=None):
detect_tampering(Config(steam_api_key="k", steam_id="i"), State())
def test_no_tampering(self) -> None:
entries = [_entry(app_id=1)]
with (
patch(f"{PKG}.load_snapshot", return_value=entries),
patch(f"{PKG}.SteamAPIClient"),
patch(f"{PKG}._check_game_tampering", return_value=None),
patch(f"{PKG}._echo"),
):
detect_tampering(Config(steam_api_key="k", steam_id="i"), State())
def test_tampering_found(self) -> None:
entries = [_entry(app_id=1, name="BadGame")]
with (
patch(f"{PKG}.load_snapshot", return_value=entries),
patch(f"{PKG}.SteamAPIClient"),
patch(
f"{PKG}._check_game_tampering",
return_value=("BadGame", 1, 3),
),
patch(f"{PKG}._echo") as mock_echo,
patch(f"{PKG}.send_notification"),
):
detect_tampering(Config(steam_api_key="k", steam_id="i"), State())
assert any("TAMPERING" in str(c) for c in mock_echo.call_args_list)
def test_stops_at_limit(self) -> None:
"""Stops after _TAMPER_CHECK_LIMIT suspicious games."""
entries = [_entry(app_id=i, name=f"G{i}") for i in range(10)]
with (
patch(f"{PKG}.load_snapshot", return_value=entries),
patch(f"{PKG}.SteamAPIClient"),
patch(
f"{PKG}._check_game_tampering",
return_value=("Game", 1, 1),
) as mock_check,
patch(f"{PKG}._echo"),
patch(f"{PKG}.send_notification"),
):
detect_tampering(Config(steam_api_key="k", steam_id="i"), State())
# Should stop after 3 (_TAMPER_CHECK_LIMIT)
assert mock_check.call_count == 3

View File

@ -0,0 +1,335 @@
"""Tests for steam_api module."""
from __future__ import annotations
from typing import Any
from unittest.mock import MagicMock, patch
import pytest
import requests
from python_pkg.steam_backlog_enforcer.steam_api import (
AchievementInfo,
GameInfo,
SteamAPIClient,
SteamAPIError,
)
class TestAchievementInfo:
"""Tests for AchievementInfo."""
def test_create(self) -> None:
a = AchievementInfo(
api_name="ACH_1", display_name="First", achieved=True, unlock_time=1000
)
assert a.api_name == "ACH_1"
assert a.achieved is True
class TestGameInfo:
"""Tests for GameInfo."""
def test_completion_pct_zero_achievements(self) -> None:
g = GameInfo(
app_id=1,
name="G",
total_achievements=0,
unlocked_achievements=0,
playtime_minutes=0,
)
assert g.completion_pct == 100.0
def test_completion_pct_partial(self) -> None:
g = GameInfo(
app_id=1,
name="G",
total_achievements=10,
unlocked_achievements=5,
playtime_minutes=0,
)
assert g.completion_pct == 50.0
def test_is_complete_true(self) -> None:
g = GameInfo(
app_id=1,
name="G",
total_achievements=5,
unlocked_achievements=5,
playtime_minutes=0,
)
assert g.is_complete is True
def test_is_complete_false(self) -> None:
g = GameInfo(
app_id=1,
name="G",
total_achievements=5,
unlocked_achievements=3,
playtime_minutes=0,
)
assert g.is_complete is False
def test_is_complete_zero(self) -> None:
g = GameInfo(
app_id=1,
name="G",
total_achievements=0,
unlocked_achievements=0,
playtime_minutes=0,
)
assert g.is_complete is False
def test_to_snapshot(self) -> None:
ach = AchievementInfo(
api_name="A1", display_name="Ach1", achieved=True, unlock_time=99
)
g = GameInfo(
app_id=1,
name="G",
total_achievements=1,
unlocked_achievements=1,
playtime_minutes=60,
achievements=[ach],
completionist_hours=5.0,
)
snap = g.to_snapshot()
assert snap["app_id"] == 1
assert snap["achievements"][0]["api_name"] == "A1"
assert snap["completionist_hours"] == 5.0
def test_from_snapshot(self) -> None:
data: dict[str, Any] = {
"app_id": 2,
"name": "G2",
"total_achievements": 3,
"unlocked_achievements": 1,
"playtime_minutes": 120,
"completionist_hours": 10.0,
"achievements": [
{
"api_name": "A1",
"display_name": "First",
"achieved": False,
"unlock_time": 0,
},
],
}
g = GameInfo.from_snapshot(data)
assert g.app_id == 2
assert g.completionist_hours == 10.0
assert len(g.achievements) == 1
def test_from_snapshot_defaults(self) -> None:
data: dict[str, Any] = {
"app_id": 3,
"name": "G3",
"total_achievements": 0,
"unlocked_achievements": 0,
}
g = GameInfo.from_snapshot(data)
assert g.playtime_minutes == 0
assert g.completionist_hours == -1
assert g.achievements == []
def test_from_snapshot_achievement_defaults(self) -> None:
data: dict[str, Any] = {
"app_id": 4,
"name": "G4",
"total_achievements": 1,
"unlocked_achievements": 0,
"achievements": [{"api_name": "X", "achieved": False}],
}
g = GameInfo.from_snapshot(data)
assert g.achievements[0].display_name == "X"
assert g.achievements[0].unlock_time == 0
class TestSteamAPIClient:
"""Tests for SteamAPIClient."""
def test_init(self) -> None:
client = SteamAPIClient("key", "id")
assert client.api_key == "key"
assert client.steam_id == "id"
def test_rate_limit(self) -> None:
client = SteamAPIClient("key", "id")
# Should not block on first call
client._rate_limit()
def test_rate_limit_throttle(self) -> None:
client = SteamAPIClient("key", "id")
# Fill up the rate limit window
client._request_times = [__import__("time").time()] * client._max_rps
with patch(
"python_pkg.steam_backlog_enforcer.steam_api.time.sleep",
) as mock_sleep:
# Next call should trigger sleep then succeed
client._rate_limit()
mock_sleep.assert_called()
def test_get_success(self) -> None:
client = SteamAPIClient("key", "id")
mock_resp = MagicMock()
mock_resp.json.return_value = {"data": "value"}
client.session.get = MagicMock(return_value=mock_resp)
result = client._get("https://example.com/api")
assert result == {"data": "value"}
def test_get_with_params(self) -> None:
client = SteamAPIClient("key", "id")
mock_resp = MagicMock()
mock_resp.json.return_value = {"data": "value"}
client.session.get = MagicMock(return_value=mock_resp)
result = client._get("https://example.com/api", params={"foo": "bar"})
assert result == {"data": "value"}
# Verify key was added to existing params dict
call_kwargs = client.session.get.call_args
assert call_kwargs[1]["params"]["foo"] == "bar"
assert call_kwargs[1]["params"]["key"] == "key"
def test_get_failure(self) -> None:
client = SteamAPIClient("key", "id")
client.session.get = MagicMock(side_effect=requests.RequestException("fail"))
with pytest.raises(SteamAPIError):
client._get("https://example.com/api")
def test_get_owned_games(self) -> None:
client = SteamAPIClient("key", "id")
with patch.object(
client,
"_get",
return_value={"response": {"games": [{"appid": 440}]}},
):
games = client.get_owned_games()
assert len(games) == 1
assert games[0]["appid"] == 440
def test_get_owned_games_empty(self) -> None:
client = SteamAPIClient("key", "id")
with patch.object(client, "_get", return_value={"response": {}}):
games = client.get_owned_games()
assert games == []
def test_get_achievement_details(self) -> None:
client = SteamAPIClient("key", "id")
with patch.object(
client,
"_get",
return_value={
"playerstats": {
"success": True,
"achievements": [
{
"apiname": "ACH_1",
"name": "First",
"achieved": 1,
"unlocktime": 1000,
},
],
},
},
):
result = client.get_achievement_details(440)
assert len(result) == 1
assert result[0].achieved is True
def test_get_achievement_details_failure(self) -> None:
client = SteamAPIClient("key", "id")
with patch.object(client, "_get", side_effect=SteamAPIError("fail")):
result = client.get_achievement_details(440)
assert result == []
def test_get_achievement_details_not_success(self) -> None:
client = SteamAPIClient("key", "id")
with patch.object(
client,
"_get",
return_value={"playerstats": {"success": False}},
):
result = client.get_achievement_details(440)
assert result == []
def test_fetch_one_game(self) -> None:
client = SteamAPIClient("key", "id")
ach = AchievementInfo("A1", "Ach1", True, 100)
with patch.object(client, "get_achievement_details", return_value=[ach]):
result = client._fetch_one_game(
{"appid": 440, "name": "TF2", "playtime_forever": 60},
set(),
)
assert result is not None
assert result.app_id == 440
def test_fetch_one_game_skipped(self) -> None:
client = SteamAPIClient("key", "id")
result = client._fetch_one_game({"appid": 440}, {440})
assert result is None
def test_fetch_one_game_no_achievements(self) -> None:
client = SteamAPIClient("key", "id")
with patch.object(client, "get_achievement_details", return_value=[]):
result = client._fetch_one_game({"appid": 440}, set())
assert result is None
def test_build_game_list(self) -> None:
client = SteamAPIClient("key", "id")
ach = AchievementInfo("A1", "Ach1", True, 100)
with (
patch.object(
client,
"get_owned_games",
return_value=[{"appid": 440, "name": "TF2", "playtime_forever": 60}],
),
patch.object(client, "get_achievement_details", return_value=[ach]),
):
progress_calls: list[tuple[int, int]] = []
def progress(c: int, t: int) -> None:
progress_calls.append((c, t))
games = client.build_game_list(progress_callback=progress)
assert len(games) == 1
assert len(progress_calls) > 0
def test_build_game_list_with_skip(self) -> None:
client = SteamAPIClient("key", "id")
with patch.object(
client,
"get_owned_games",
return_value=[{"appid": 440, "name": "TF2"}],
):
games = client.build_game_list(skip_app_ids=[440])
assert games == []
def test_build_game_list_exception_in_future(self) -> None:
client = SteamAPIClient("key", "id")
with (
patch.object(
client,
"get_owned_games",
return_value=[{"appid": 440, "name": "TF2"}],
),
patch.object(
client,
"get_achievement_details",
side_effect=SteamAPIError("err"),
),
):
games = client.build_game_list()
assert games == []
def test_refresh_single_game(self) -> None:
client = SteamAPIClient("key", "id")
ach = AchievementInfo("A1", "Ach1", True, 100)
with patch.object(client, "get_achievement_details", return_value=[ach]):
result = client.refresh_single_game(440, "TF2", 60)
assert result is not None
assert result.unlocked_achievements == 1
def test_refresh_single_game_no_achievements(self) -> None:
client = SteamAPIClient("key", "id")
with patch.object(client, "get_achievement_details", return_value=[]):
result = client.refresh_single_game(440, "TF2")
assert result is None

View File

@ -0,0 +1,470 @@
"""Tests for store_blocker module."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from unittest.mock import MagicMock, patch
from python_pkg.steam_backlog_enforcer.store_blocker import (
_block_store_iptables,
_block_via_hosts_install,
_is_iptables_blocked,
_unblock_store_iptables,
block_store,
is_store_blocked,
unblock_store,
)
if TYPE_CHECKING:
from pathlib import Path
class TestIsStoreBlocked:
"""Tests for is_store_blocked."""
def test_blocked_in_hosts(self, tmp_path: Path) -> None:
hosts_file = tmp_path / "hosts"
hosts_file.write_text("0.0.0.0 store.steampowered.com\n", encoding="utf-8")
with (
patch(
"python_pkg.steam_backlog_enforcer.store_blocker.HOSTS_FILE",
hosts_file,
),
):
assert is_store_blocked() is True
def test_commented_in_hosts(self, tmp_path: Path) -> None:
hosts_file = tmp_path / "hosts"
hosts_file.write_text("# 0.0.0.0 store.steampowered.com\n", encoding="utf-8")
with (
patch(
"python_pkg.steam_backlog_enforcer.store_blocker.HOSTS_FILE",
hosts_file,
),
patch(
"python_pkg.steam_backlog_enforcer.store_blocker._is_iptables_blocked",
return_value=False,
),
):
assert is_store_blocked() is False
def test_not_in_hosts_iptables_blocked(self, tmp_path: Path) -> None:
hosts_file = tmp_path / "hosts"
hosts_file.write_text("127.0.0.1 localhost\n", encoding="utf-8")
with (
patch(
"python_pkg.steam_backlog_enforcer.store_blocker.HOSTS_FILE",
hosts_file,
),
patch(
"python_pkg.steam_backlog_enforcer.store_blocker._is_iptables_blocked",
return_value=True,
),
):
assert is_store_blocked() is True
def test_hosts_read_error(self, tmp_path: Path) -> None:
hosts_file = tmp_path / "nonexistent"
with (
patch(
"python_pkg.steam_backlog_enforcer.store_blocker.HOSTS_FILE",
hosts_file,
),
patch(
"python_pkg.steam_backlog_enforcer.store_blocker._is_iptables_blocked",
return_value=False,
),
):
assert is_store_blocked() is False
def test_wrong_redirect_ip(self, tmp_path: Path) -> None:
hosts_file = tmp_path / "hosts"
hosts_file.write_text("127.0.0.1 store.steampowered.com\n", encoding="utf-8")
with (
patch(
"python_pkg.steam_backlog_enforcer.store_blocker.HOSTS_FILE",
hosts_file,
),
patch(
"python_pkg.steam_backlog_enforcer.store_blocker._is_iptables_blocked",
return_value=False,
),
):
assert is_store_blocked() is False
class TestBlockStore:
"""Tests for block_store."""
def test_already_blocked(self) -> None:
with patch(
"python_pkg.steam_backlog_enforcer.store_blocker.is_store_blocked",
return_value=True,
):
assert block_store() is True
def test_reblock_succeeds(self) -> None:
with (
patch(
"python_pkg.steam_backlog_enforcer.store_blocker.is_store_blocked",
side_effect=[False, True],
),
patch(
"python_pkg.steam_backlog_enforcer.store_blocker._reblock_hosts",
return_value=True,
),
patch(
"python_pkg.steam_backlog_enforcer.store_blocker._block_store_iptables",
),
patch(
"python_pkg.steam_backlog_enforcer.store_blocker.flush_dns_cache",
),
):
assert block_store() is True
def test_fallback_to_install_script(self) -> None:
with (
patch(
"python_pkg.steam_backlog_enforcer.store_blocker.is_store_blocked",
side_effect=[False, False],
),
patch(
"python_pkg.steam_backlog_enforcer.store_blocker._reblock_hosts",
return_value=False,
),
patch(
"python_pkg.steam_backlog_enforcer.store_blocker._block_via_hosts_install",
return_value=True,
),
patch(
"python_pkg.steam_backlog_enforcer.store_blocker._block_store_iptables",
return_value=False,
),
patch(
"python_pkg.steam_backlog_enforcer.store_blocker.flush_dns_cache",
),
):
assert block_store() is True
def test_all_fail(self) -> None:
with (
patch(
"python_pkg.steam_backlog_enforcer.store_blocker.is_store_blocked",
side_effect=[False, False],
),
patch(
"python_pkg.steam_backlog_enforcer.store_blocker._reblock_hosts",
return_value=False,
),
patch(
"python_pkg.steam_backlog_enforcer.store_blocker._block_via_hosts_install",
return_value=False,
),
patch(
"python_pkg.steam_backlog_enforcer.store_blocker._block_store_iptables",
return_value=False,
),
):
assert block_store() is False
def test_iptables_only_succeeds(self) -> None:
with (
patch(
"python_pkg.steam_backlog_enforcer.store_blocker.is_store_blocked",
side_effect=[False, False],
),
patch(
"python_pkg.steam_backlog_enforcer.store_blocker._reblock_hosts",
return_value=False,
),
patch(
"python_pkg.steam_backlog_enforcer.store_blocker._block_via_hosts_install",
return_value=False,
),
patch(
"python_pkg.steam_backlog_enforcer.store_blocker._block_store_iptables",
return_value=True,
),
patch(
"python_pkg.steam_backlog_enforcer.store_blocker.flush_dns_cache",
),
):
assert block_store() is True
class TestBlockViaHostsInstall:
"""Tests for _block_via_hosts_install."""
def test_already_blocked(self) -> None:
with patch(
"python_pkg.steam_backlog_enforcer.store_blocker.is_store_blocked",
return_value=True,
):
assert _block_via_hosts_install() is True
def test_script_missing(self, tmp_path: Path) -> None:
with (
patch(
"python_pkg.steam_backlog_enforcer.store_blocker.is_store_blocked",
return_value=False,
),
patch(
"python_pkg.steam_backlog_enforcer.store_blocker.HOSTS_INSTALL_SCRIPT",
tmp_path / "nonexistent.sh",
),
):
assert _block_via_hosts_install() is False
def test_script_succeeds(self, tmp_path: Path) -> None:
script = tmp_path / "install.sh"
script.touch()
mock_result = MagicMock(returncode=0)
with (
patch(
"python_pkg.steam_backlog_enforcer.store_blocker.is_store_blocked",
return_value=False,
),
patch(
"python_pkg.steam_backlog_enforcer.store_blocker.HOSTS_INSTALL_SCRIPT",
script,
),
patch(
"python_pkg.steam_backlog_enforcer.store_blocker.subprocess.run",
return_value=mock_result,
),
):
assert _block_via_hosts_install() is True
def test_script_fails(self, tmp_path: Path) -> None:
script = tmp_path / "install.sh"
script.touch()
mock_result = MagicMock(returncode=1, stderr="error", stdout="")
with (
patch(
"python_pkg.steam_backlog_enforcer.store_blocker.is_store_blocked",
return_value=False,
),
patch(
"python_pkg.steam_backlog_enforcer.store_blocker.HOSTS_INSTALL_SCRIPT",
script,
),
patch(
"python_pkg.steam_backlog_enforcer.store_blocker.subprocess.run",
return_value=mock_result,
),
):
assert _block_via_hosts_install() is False
def test_script_fails_no_stderr(self, tmp_path: Path) -> None:
script = tmp_path / "install.sh"
script.touch()
mock_result = MagicMock(returncode=1, stderr="", stdout="out")
with (
patch(
"python_pkg.steam_backlog_enforcer.store_blocker.is_store_blocked",
return_value=False,
),
patch(
"python_pkg.steam_backlog_enforcer.store_blocker.HOSTS_INSTALL_SCRIPT",
script,
),
patch(
"python_pkg.steam_backlog_enforcer.store_blocker.subprocess.run",
return_value=mock_result,
),
):
assert _block_via_hosts_install() is False
def test_script_os_error(self, tmp_path: Path) -> None:
script = tmp_path / "install.sh"
script.touch()
with (
patch(
"python_pkg.steam_backlog_enforcer.store_blocker.is_store_blocked",
return_value=False,
),
patch(
"python_pkg.steam_backlog_enforcer.store_blocker.HOSTS_INSTALL_SCRIPT",
script,
),
patch(
"python_pkg.steam_backlog_enforcer.store_blocker.subprocess.run",
side_effect=OSError,
),
):
assert _block_via_hosts_install() is False
class TestIsIptablesBlocked:
"""Tests for _is_iptables_blocked."""
def test_blocked(self) -> None:
mock_result = MagicMock(returncode=0, stdout="DROP blah")
with patch(
"python_pkg.steam_backlog_enforcer.store_blocker.subprocess.run",
return_value=mock_result,
):
assert _is_iptables_blocked() is True
def test_not_blocked_no_drop(self) -> None:
mock_result = MagicMock(returncode=0, stdout="ACCEPT")
with patch(
"python_pkg.steam_backlog_enforcer.store_blocker.subprocess.run",
return_value=mock_result,
):
assert _is_iptables_blocked() is False
def test_not_blocked_error(self) -> None:
mock_result = MagicMock(returncode=1, stdout="")
with patch(
"python_pkg.steam_backlog_enforcer.store_blocker.subprocess.run",
return_value=mock_result,
):
assert _is_iptables_blocked() is False
def test_os_error(self) -> None:
with patch(
"python_pkg.steam_backlog_enforcer.store_blocker.subprocess.run",
side_effect=OSError,
):
assert _is_iptables_blocked() is False
class TestBlockStoreIptables:
"""Tests for _block_store_iptables."""
def test_success(self) -> None:
mock_result = MagicMock(returncode=0)
with (
patch(
"python_pkg.steam_backlog_enforcer.store_blocker.subprocess.run",
return_value=mock_result,
),
patch(
"python_pkg.steam_backlog_enforcer.store_blocker.socket.getaddrinfo",
return_value=[
(None, None, None, None, ("1.2.3.4", 443)),
],
),
):
assert _block_store_iptables() is True
def test_os_error(self) -> None:
with patch(
"python_pkg.steam_backlog_enforcer.store_blocker.subprocess.run",
side_effect=OSError,
):
assert _block_store_iptables() is False
def test_dns_resolution_fails(self) -> None:
import socket
mock_result = MagicMock(returncode=0)
with (
patch(
"python_pkg.steam_backlog_enforcer.store_blocker.subprocess.run",
return_value=mock_result,
),
patch(
"python_pkg.steam_backlog_enforcer.store_blocker.socket.getaddrinfo",
side_effect=socket.gaierror,
),
):
# Should succeed even if DNS fails (just no IPs to block)
assert _block_store_iptables() is True
def test_chain_hook_needed(self) -> None:
results = [
MagicMock(returncode=0), # -N
MagicMock(returncode=0), # -F
MagicMock(returncode=1), # -C OUTPUT (not hooked)
MagicMock(returncode=0), # -I OUTPUT
]
call_count = 0
def side_effect(*args: Any, **kwargs: Any) -> MagicMock:
nonlocal call_count
idx = min(call_count, len(results) - 1)
call_count += 1
return results[idx]
with (
patch(
"python_pkg.steam_backlog_enforcer.store_blocker.subprocess.run",
side_effect=side_effect,
),
patch(
"python_pkg.steam_backlog_enforcer.store_blocker.socket.getaddrinfo",
side_effect=__import__("socket").gaierror,
),
):
assert _block_store_iptables() is True
class TestUnblockStore:
"""Tests for unblock_store."""
def test_both_succeed(self) -> None:
with (
patch(
"python_pkg.steam_backlog_enforcer.store_blocker._unblock_store_iptables",
return_value=True,
),
patch(
"python_pkg.steam_backlog_enforcer.store_blocker._unblock_hosts",
return_value=True,
),
patch(
"python_pkg.steam_backlog_enforcer.store_blocker.flush_dns_cache",
),
):
assert unblock_store() is True
def test_iptables_fails(self) -> None:
with (
patch(
"python_pkg.steam_backlog_enforcer.store_blocker._unblock_store_iptables",
return_value=False,
),
patch(
"python_pkg.steam_backlog_enforcer.store_blocker._unblock_hosts",
return_value=True,
),
patch(
"python_pkg.steam_backlog_enforcer.store_blocker.flush_dns_cache",
),
):
assert unblock_store() is True
def test_both_fail(self) -> None:
with (
patch(
"python_pkg.steam_backlog_enforcer.store_blocker._unblock_store_iptables",
return_value=False,
),
patch(
"python_pkg.steam_backlog_enforcer.store_blocker._unblock_hosts",
return_value=False,
),
patch(
"python_pkg.steam_backlog_enforcer.store_blocker.flush_dns_cache",
),
):
assert unblock_store() is False
class TestUnblockStoreIptables:
"""Tests for _unblock_store_iptables."""
def test_success(self) -> None:
with patch(
"python_pkg.steam_backlog_enforcer.store_blocker.subprocess.run",
):
assert _unblock_store_iptables() is True
def test_os_error(self) -> None:
with patch(
"python_pkg.steam_backlog_enforcer.store_blocker.subprocess.run",
side_effect=OSError,
):
assert _unblock_store_iptables() is False

View File

@ -0,0 +1,200 @@
"""Tests for store_blocker module — part 2 (missing coverage)."""
from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
from python_pkg.steam_backlog_enforcer.store_blocker import (
_disable_hosts_protection,
_enable_hosts_protection,
_reblock_hosts,
_sudo_write_hosts,
_unblock_hosts,
flush_dns_cache,
)
if TYPE_CHECKING:
from pathlib import Path
PKG = "python_pkg.steam_backlog_enforcer.store_blocker"
class TestSudoWriteHosts:
"""Tests for _sudo_write_hosts."""
def test_writes_content(self) -> None:
with patch(f"{PKG}.subprocess.run") as mock_run:
_sudo_write_hosts("127.0.0.1 localhost\n")
mock_run.assert_called_once()
assert mock_run.call_args.kwargs["input"] == b"127.0.0.1 localhost\n"
class TestDisableHostsProtection:
"""Tests for _disable_hosts_protection."""
def test_stops_services_unmounts_chattr(self) -> None:
findmnt_found = MagicMock(returncode=0)
def run_side_effect(
cmd: list[str],
**kwargs: object,
) -> MagicMock:
if any("findmnt" in str(c) for c in cmd):
return findmnt_found
return MagicMock(returncode=0)
with patch(f"{PKG}.subprocess.run", side_effect=run_side_effect):
_disable_hosts_protection()
def test_no_bind_mount(self) -> None:
findmnt_missing = MagicMock(returncode=1)
def run_side_effect(
cmd: list[str],
**kwargs: object,
) -> MagicMock:
if any("findmnt" in str(c) for c in cmd):
return findmnt_missing
return MagicMock(returncode=0)
with patch(f"{PKG}.subprocess.run", side_effect=run_side_effect):
_disable_hosts_protection()
class TestEnableHostsProtection:
"""Tests for _enable_hosts_protection."""
def test_with_locked_copy(self, tmp_path: Path) -> None:
locked_copy = tmp_path / "locked-hosts"
locked_copy.touch()
with (
patch(f"{PKG}.subprocess.run"),
patch(f"{PKG}._LOCKED_HOSTS_COPY", locked_copy),
):
_enable_hosts_protection()
def test_without_locked_copy(self, tmp_path: Path) -> None:
locked_copy = tmp_path / "nonexistent"
with (
patch(f"{PKG}.subprocess.run"),
patch(f"{PKG}._LOCKED_HOSTS_COPY", locked_copy),
):
_enable_hosts_protection()
class TestUnblockHosts:
"""Tests for _unblock_hosts."""
def test_not_blocked(self) -> None:
with patch(f"{PKG}.is_store_blocked", return_value=False):
result = _unblock_hosts()
assert result is True
def test_comments_out_entries(self, tmp_path: Path) -> None:
hosts_file = tmp_path / "hosts"
hosts_file.write_text(
"127.0.0.1 localhost\n"
"0.0.0.0 store.steampowered.com\n"
"0.0.0.0 checkout.steampowered.com\n",
encoding="utf-8",
)
with (
patch(f"{PKG}.is_store_blocked", return_value=True),
patch(f"{PKG}.HOSTS_FILE", hosts_file),
patch(f"{PKG}._disable_hosts_protection"),
patch(f"{PKG}._enable_hosts_protection"),
patch(f"{PKG}._sudo_write_hosts") as mock_write,
):
result = _unblock_hosts()
assert result is True
written = mock_write.call_args[0][0]
assert "# 0.0.0.0 store.steampowered.com" in written
def test_no_change_needed(self, tmp_path: Path) -> None:
hosts_file = tmp_path / "hosts"
hosts_file.write_text(
"# 0.0.0.0 store.steampowered.com\n",
encoding="utf-8",
)
with (
patch(f"{PKG}.is_store_blocked", return_value=True),
patch(f"{PKG}.HOSTS_FILE", hosts_file),
patch(f"{PKG}._disable_hosts_protection"),
patch(f"{PKG}._enable_hosts_protection"),
patch(f"{PKG}._sudo_write_hosts") as mock_write,
):
result = _unblock_hosts()
assert result is True
mock_write.assert_not_called()
def test_os_error(self) -> None:
with (
patch(f"{PKG}.is_store_blocked", return_value=True),
patch(f"{PKG}._disable_hosts_protection", side_effect=OSError),
):
result = _unblock_hosts()
assert result is False
class TestReblockHosts:
"""Tests for _reblock_hosts."""
def test_uncomments_entries(self, tmp_path: Path) -> None:
hosts_file = tmp_path / "hosts"
hosts_file.write_text(
"127.0.0.1 localhost\n"
"# 0.0.0.0 store.steampowered.com\n"
"# 0.0.0.0 checkout.steampowered.com\n",
encoding="utf-8",
)
with (
patch(f"{PKG}.HOSTS_FILE", hosts_file),
patch(f"{PKG}._disable_hosts_protection"),
patch(f"{PKG}._enable_hosts_protection"),
patch(f"{PKG}._sudo_write_hosts") as mock_write,
):
result = _reblock_hosts()
assert result is True
written = mock_write.call_args[0][0]
# Should have uncommented lines
assert "0.0.0.0 store.steampowered.com" in written
assert "# 0.0.0.0 store.steampowered.com" not in written
def test_no_change(self, tmp_path: Path) -> None:
hosts_file = tmp_path / "hosts"
hosts_file.write_text("127.0.0.1 localhost\n", encoding="utf-8")
with (
patch(f"{PKG}.HOSTS_FILE", hosts_file),
patch(f"{PKG}._disable_hosts_protection"),
patch(f"{PKG}._enable_hosts_protection"),
patch(f"{PKG}._sudo_write_hosts") as mock_write,
):
result = _reblock_hosts()
assert result is True
mock_write.assert_not_called()
def test_os_error(self) -> None:
with patch(f"{PKG}._disable_hosts_protection", side_effect=OSError):
result = _reblock_hosts()
assert result is False
class TestFlushDnsCache:
"""Tests for flush_dns_cache."""
def test_runs_commands(self) -> None:
with patch(f"{PKG}.subprocess.run") as mock_run:
flush_dns_cache()
assert mock_run.call_count == 3
def test_file_not_found_suppressed(self) -> None:
with patch(
f"{PKG}.subprocess.run",
side_effect=FileNotFoundError,
):
flush_dns_cache()
def test_os_error_suppressed(self) -> None:
with patch(f"{PKG}.subprocess.run", side_effect=OSError):
flush_dns_cache()