mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-07-04 15:23:05 +02:00
Replaces the auto-reassign-to-shorter-game logic (which fired while the current game was still in progress) with a strict workflow: 1. Check if assigned game is finished. 2. If not, do nothing. 3. If yes, pick the next shortest game and prompt the user. 4. If the user skips, ignore that game for 7 days and pick the next shortest candidate. Changes: - State: add skipped_until + skip_for_days + active_skipped_ids. - scanning.pick_next_game: optional on_select callback drives a sequential picker that filters skipped IDs; legacy cmd_pick flow preserved when on_select is None. - _cmd_done._finalize_completion: pick + prompt via on_select. - _cmd_done: remove _try_reassign_shorter_game and helpers (_apply_cached_confidence_to_games, _should_reassign_candidate, _echo_reassign_decision, _evaluate_reassign_iteration) plus call site in cmd_done. - Tests: drop obsolete _try_reassign_shorter_game suite; add TestPromptKeepOrSkip, TestPickNextGameSequential, and State skipped_until tests.
425 lines
16 KiB
Python
425 lines
16 KiB
Python
"""Tests for main CLI 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._cmd_done import (
|
|
_enforce_on_done,
|
|
_finalize_completion,
|
|
cmd_done,
|
|
)
|
|
from python_pkg.steam_backlog_enforcer.config import Config, State
|
|
from python_pkg.steam_backlog_enforcer.steam_api import GameInfo
|
|
|
|
CMD_DONE_PKG = "python_pkg.steam_backlog_enforcer._cmd_done"
|
|
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"{CMD_DONE_PKG}._echo"),
|
|
patch(f"{CMD_DONE_PKG}.load_snapshot", return_value=snap),
|
|
patch(f"{CMD_DONE_PKG}.pick_next_game") as mock_pick,
|
|
patch(f"{CMD_DONE_PKG}.get_all_owned_app_ids", return_value=[1, 2, 3]),
|
|
patch(f"{CMD_DONE_PKG}.hide_other_games", return_value=2),
|
|
patch(f"{CMD_DONE_PKG}.send_notification"),
|
|
patch.object(State, "save"),
|
|
):
|
|
|
|
def set_next(
|
|
_games: object,
|
|
s: State,
|
|
_c: object,
|
|
**_kwargs: 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"{CMD_DONE_PKG}._echo"),
|
|
patch(f"{CMD_DONE_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"{CMD_DONE_PKG}._echo"),
|
|
patch(f"{CMD_DONE_PKG}.load_snapshot", return_value=snap),
|
|
patch(f"{CMD_DONE_PKG}.pick_next_game") as mock_pick,
|
|
patch.object(State, "save"),
|
|
):
|
|
|
|
def set_none(
|
|
_games: object,
|
|
s: State,
|
|
_c: object,
|
|
**_kwargs: 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"{CMD_DONE_PKG}._echo"),
|
|
patch(f"{CMD_DONE_PKG}.load_snapshot", return_value=snap),
|
|
patch(f"{CMD_DONE_PKG}.pick_next_game") as mock_pick,
|
|
patch(f"{CMD_DONE_PKG}.get_all_owned_app_ids", return_value=[]),
|
|
patch(f"{CMD_DONE_PKG}.send_notification"),
|
|
patch.object(State, "save"),
|
|
):
|
|
|
|
def set_2(
|
|
_games: object,
|
|
s: State,
|
|
_c: object,
|
|
**_kwargs: 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"{CMD_DONE_PKG}._echo"),
|
|
patch(f"{CMD_DONE_PKG}.load_snapshot", return_value=snap),
|
|
patch(f"{CMD_DONE_PKG}.pick_next_game") as mock_pick,
|
|
patch(f"{CMD_DONE_PKG}.get_all_owned_app_ids", return_value=[1, 2]),
|
|
patch(f"{CMD_DONE_PKG}.hide_other_games", return_value=0),
|
|
patch(f"{CMD_DONE_PKG}.send_notification"),
|
|
patch.object(State, "save"),
|
|
):
|
|
|
|
def set_2(
|
|
_games: object,
|
|
s: State,
|
|
_c: object,
|
|
**_kwargs: 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_refreshes_snapshot_hours_before_pick(self) -> None:
|
|
"""Ensure stale snapshot hours are replaced before picking next game."""
|
|
config = Config()
|
|
state = State(current_app_id=1, current_game_name="G")
|
|
snap = [
|
|
_snap(2, "A Space for the Unbound", 10, 0, 0.56),
|
|
_snap(3, "Lacuna", 10, 0, 1.2),
|
|
]
|
|
seen: dict[int, float] = {}
|
|
|
|
def capture_pick(
|
|
games: list[GameInfo],
|
|
s: State,
|
|
_c: object,
|
|
**_kwargs: object,
|
|
) -> None:
|
|
for game in games:
|
|
seen[game.app_id] = game.completionist_hours
|
|
# Force early return path after pick_next_game.
|
|
s.current_app_id = None
|
|
|
|
with (
|
|
patch(f"{CMD_DONE_PKG}._echo"),
|
|
patch(f"{CMD_DONE_PKG}.load_snapshot", return_value=snap),
|
|
patch(f"{CMD_DONE_PKG}.load_hltb_cache", return_value={2: 20.05}),
|
|
patch(
|
|
f"{CMD_DONE_PKG}.fetch_hltb_times_cached",
|
|
return_value={3: 18.81},
|
|
) as mock_fetch_hltb,
|
|
patch(f"{CMD_DONE_PKG}.pick_next_game", side_effect=capture_pick),
|
|
patch.object(State, "save"),
|
|
):
|
|
_finalize_completion(config, state, "G", 1)
|
|
|
|
assert seen[2] == 20.05
|
|
assert seen[3] == 18.81
|
|
mock_fetch_hltb.assert_called_once_with([(3, "Lacuna")])
|
|
|
|
def test_retriggers_install_after_library_hide_if_still_missing(self) -> None:
|
|
"""Re-trigger install after hide step in case Steam restart drops it."""
|
|
config = Config(steam_id="sid")
|
|
state = State(current_app_id=1, current_game_name="DoneGame")
|
|
snap = [_snap(2, "Next", 10, 0, 5.0)]
|
|
|
|
def set_next(
|
|
_games: object,
|
|
s: State,
|
|
_c: object,
|
|
**_kwargs: object,
|
|
) -> None:
|
|
s.current_app_id = 2
|
|
s.current_game_name = "Next"
|
|
|
|
with (
|
|
patch(f"{CMD_DONE_PKG}._echo"),
|
|
patch(f"{CMD_DONE_PKG}.load_snapshot", return_value=snap),
|
|
patch(f"{CMD_DONE_PKG}.pick_next_game", side_effect=set_next),
|
|
patch(f"{CMD_DONE_PKG}.get_all_owned_app_ids", return_value=[1, 2]),
|
|
patch(f"{CMD_DONE_PKG}.hide_other_games", return_value=1),
|
|
patch(f"{CMD_DONE_PKG}.is_game_installed", return_value=False),
|
|
patch(f"{CMD_DONE_PKG}.install_game") as mock_install,
|
|
patch(f"{CMD_DONE_PKG}.send_notification"),
|
|
patch.object(State, "save"),
|
|
):
|
|
_finalize_completion(config, state, "DoneGame", 1)
|
|
|
|
mock_install.assert_called_once_with(2, "Next", "sid", use_steam_protocol=True)
|
|
|
|
def test_skips_install_retry_when_assigned_game_already_installed(self) -> None:
|
|
"""Do not re-trigger install when assigned game is already present."""
|
|
config = Config(steam_id="sid")
|
|
state = State(current_app_id=1, current_game_name="DoneGame")
|
|
snap = [_snap(2, "Next", 10, 0, 5.0)]
|
|
|
|
def set_next(
|
|
_games: object,
|
|
s: State,
|
|
_c: object,
|
|
**_kwargs: object,
|
|
) -> None:
|
|
s.current_app_id = 2
|
|
s.current_game_name = "Next"
|
|
|
|
with (
|
|
patch(f"{CMD_DONE_PKG}._echo"),
|
|
patch(f"{CMD_DONE_PKG}.load_snapshot", return_value=snap),
|
|
patch(f"{CMD_DONE_PKG}.pick_next_game", side_effect=set_next),
|
|
patch(f"{CMD_DONE_PKG}.get_all_owned_app_ids", return_value=[1, 2]),
|
|
patch(f"{CMD_DONE_PKG}.hide_other_games", return_value=1),
|
|
patch(f"{CMD_DONE_PKG}.is_game_installed", return_value=True),
|
|
patch(f"{CMD_DONE_PKG}.install_game") as mock_install,
|
|
patch(f"{CMD_DONE_PKG}.send_notification"),
|
|
patch.object(State, "save"),
|
|
):
|
|
_finalize_completion(config, state, "DoneGame", 1)
|
|
|
|
mock_install.assert_not_called()
|
|
|
|
|
|
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"{CMD_DONE_PKG}._echo"),
|
|
patch(
|
|
f"{CMD_DONE_PKG}.enforce_allowed_game",
|
|
return_value=[(1234, 999)],
|
|
),
|
|
patch(f"{CMD_DONE_PKG}.uninstall_other_games", return_value=2),
|
|
patch(f"{CMD_DONE_PKG}.is_game_installed", return_value=True),
|
|
patch(f"{CMD_DONE_PKG}.get_all_owned_app_ids", return_value=[1, 2]),
|
|
patch(f"{CMD_DONE_PKG}.hide_other_games", return_value=1),
|
|
):
|
|
_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"{CMD_DONE_PKG}._echo"),
|
|
patch(f"{CMD_DONE_PKG}.enforce_allowed_game", return_value=[]),
|
|
patch(f"{CMD_DONE_PKG}.uninstall_other_games", return_value=0),
|
|
patch(f"{CMD_DONE_PKG}.is_game_installed", return_value=True),
|
|
patch(f"{CMD_DONE_PKG}.get_all_owned_app_ids", return_value=[]),
|
|
patch(f"{CMD_DONE_PKG}.hide_other_games", return_value=0),
|
|
):
|
|
_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"{CMD_DONE_PKG}._echo"),
|
|
patch(f"{CMD_DONE_PKG}.is_game_installed", return_value=False),
|
|
patch(f"{CMD_DONE_PKG}.install_game") as mock_install,
|
|
patch(f"{CMD_DONE_PKG}.get_all_owned_app_ids", return_value=[1, 2]),
|
|
patch(f"{CMD_DONE_PKG}.hide_other_games", return_value=0),
|
|
):
|
|
_enforce_on_done(config, state)
|
|
mock_install.assert_called_once_with(1, "G", "s1", use_steam_protocol=True)
|
|
|
|
"""Tests for cmd_done."""
|
|
|
|
def test_no_game_assigned(self) -> None:
|
|
with patch(f"{CMD_DONE_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"{CMD_DONE_PKG}.SteamAPIClient", return_value=mock_client),
|
|
patch(f"{CMD_DONE_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"{CMD_DONE_PKG}.SteamAPIClient", return_value=mock_client),
|
|
patch(f"{CMD_DONE_PKG}._echo"),
|
|
patch(f"{CMD_DONE_PKG}.load_hltb_cache", return_value={1: 20.0}),
|
|
patch(f"{CMD_DONE_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"{CMD_DONE_PKG}.SteamAPIClient", return_value=mock_client),
|
|
patch(f"{CMD_DONE_PKG}._echo"),
|
|
patch(f"{CMD_DONE_PKG}.load_hltb_cache", return_value={1: 10.0}),
|
|
patch(f"{CMD_DONE_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"{CMD_DONE_PKG}.SteamAPIClient", return_value=mock_client),
|
|
patch(f"{CMD_DONE_PKG}._echo"),
|
|
patch(f"{CMD_DONE_PKG}.load_hltb_cache", return_value={}),
|
|
patch(
|
|
f"{CMD_DONE_PKG}.fetch_hltb_times_cached",
|
|
return_value={1: 15.0},
|
|
),
|
|
patch(f"{CMD_DONE_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"{CMD_DONE_PKG}.SteamAPIClient", return_value=mock_client),
|
|
patch(f"{CMD_DONE_PKG}._echo"),
|
|
patch(f"{CMD_DONE_PKG}.load_hltb_cache", return_value={1: -1.0}),
|
|
patch(f"{CMD_DONE_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=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"{CMD_DONE_PKG}.SteamAPIClient", return_value=mock_client),
|
|
patch(f"{CMD_DONE_PKG}._echo"),
|
|
patch(f"{CMD_DONE_PKG}.load_hltb_cache", return_value={1: 50.0}),
|
|
patch(f"{CMD_DONE_PKG}._finalize_completion"),
|
|
):
|
|
cmd_done(Config(steam_api_key="k", steam_id="i"), state)
|