steam-backlog-enforcer/steam_backlog_enforcer/tests/test_main_part2.py
Krzysztof kuhy Rudnicki f7d68bc062 steam_backlog_enforcer: only prompt next pick after game is finished
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.
2026-05-23 21:19:44 +02:00

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)