mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-11-11 16:53:50 +01:00
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:
parent
de4134fcf4
commit
1f02540eb5
@ -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,
|
||||
|
||||
@ -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);
|
||||
}}
|
||||
|
||||
0
steam_backlog_enforcer/tests/__init__.py
Normal file
0
steam_backlog_enforcer/tests/__init__.py
Normal file
177
steam_backlog_enforcer/tests/test_config.py
Normal file
177
steam_backlog_enforcer/tests/test_config.py
Normal 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()
|
||||
352
steam_backlog_enforcer/tests/test_enforce_loop.py
Normal file
352
steam_backlog_enforcer/tests/test_enforce_loop.py
Normal 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
|
||||
192
steam_backlog_enforcer/tests/test_enforcer.py
Normal file
192
steam_backlog_enforcer/tests/test_enforcer.py
Normal 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
|
||||
475
steam_backlog_enforcer/tests/test_game_install.py
Normal file
475
steam_backlog_enforcer/tests/test_game_install.py
Normal 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
|
||||
163
steam_backlog_enforcer/tests/test_game_install_part2.py
Normal file
163
steam_backlog_enforcer/tests/test_game_install_part2.py
Normal 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()
|
||||
474
steam_backlog_enforcer/tests/test_hltb.py
Normal file
474
steam_backlog_enforcer/tests/test_hltb.py
Normal 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 == []
|
||||
135
steam_backlog_enforcer/tests/test_hltb_part2.py
Normal file
135
steam_backlog_enforcer/tests/test_hltb_part2.py
Normal 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
|
||||
45
steam_backlog_enforcer/tests/test_hltb_part3.py
Normal file
45
steam_backlog_enforcer/tests/test_hltb_part3.py
Normal 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 == []
|
||||
497
steam_backlog_enforcer/tests/test_library_hider.py
Normal file
497
steam_backlog_enforcer/tests/test_library_hider.py
Normal 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
|
||||
114
steam_backlog_enforcer/tests/test_library_hider_part2.py
Normal file
114
steam_backlog_enforcer/tests/test_library_hider_part2.py
Normal 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()
|
||||
483
steam_backlog_enforcer/tests/test_main.py
Normal file
483
steam_backlog_enforcer/tests/test_main.py
Normal 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
|
||||
376
steam_backlog_enforcer/tests/test_main_part2.py
Normal file
376
steam_backlog_enforcer/tests/test_main_part2.py
Normal 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()
|
||||
248
steam_backlog_enforcer/tests/test_protondb.py
Normal file
248
steam_backlog_enforcer/tests/test_protondb.py
Normal 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"
|
||||
439
steam_backlog_enforcer/tests/test_scanning.py
Normal file
439
steam_backlog_enforcer/tests/test_scanning.py
Normal 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)
|
||||
134
steam_backlog_enforcer/tests/test_scanning_part2.py
Normal file
134
steam_backlog_enforcer/tests/test_scanning_part2.py
Normal 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
|
||||
335
steam_backlog_enforcer/tests/test_steam_api.py
Normal file
335
steam_backlog_enforcer/tests/test_steam_api.py
Normal 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
|
||||
470
steam_backlog_enforcer/tests/test_store_blocker.py
Normal file
470
steam_backlog_enforcer/tests/test_store_blocker.py
Normal 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
|
||||
200
steam_backlog_enforcer/tests/test_store_blocker_part2.py
Normal file
200
steam_backlog_enforcer/tests/test_store_blocker_part2.py
Normal 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()
|
||||
Loading…
Reference in New Issue
Block a user