mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-07-04 13:23:18 +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.
306 lines
11 KiB
Python
306 lines
11 KiB
Python
"""Tests for main CLI module — part 3 (cmd_done, main, cmd_pick)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
from typing import Any
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from python_pkg.steam_backlog_enforcer._cmd_done import (
|
|
cmd_done,
|
|
)
|
|
from python_pkg.steam_backlog_enforcer.config import Config, State
|
|
from python_pkg.steam_backlog_enforcer.main import cmd_pick, main
|
|
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,
|
|
name: str,
|
|
total: int,
|
|
unlocked: int,
|
|
hours: float,
|
|
) -> dict[str, Any]:
|
|
return {
|
|
"app_id": app_id,
|
|
"name": name,
|
|
"total_achievements": total,
|
|
"unlocked_achievements": unlocked,
|
|
"playtime_minutes": 0,
|
|
"completionist_hours": hours,
|
|
"achievements": [],
|
|
}
|
|
|
|
|
|
class TestCmdDone:
|
|
"""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)
|
|
|
|
|
|
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()
|
|
|
|
|
|
class TestCmdPick:
|
|
"""Tests for cmd_pick."""
|
|
|
|
def test_no_snapshot_prints_message(self) -> None:
|
|
with (
|
|
patch(f"{PKG}.load_snapshot", return_value=[]),
|
|
patch(f"{PKG}._echo") as mock_echo,
|
|
):
|
|
cmd_pick(Config(steam_api_key="k", steam_id="i"), State())
|
|
mock_echo.assert_called_once_with("No snapshot found. Run 'scan' first.")
|
|
|
|
def test_calls_pick_next_game(self) -> None:
|
|
snap = [_snap(2, "NewGame", 10, 0, 5.0)]
|
|
with (
|
|
patch(f"{PKG}.load_snapshot", return_value=snap),
|
|
patch(f"{PKG}.load_hltb_cache", return_value={2: 5.0}),
|
|
patch(f"{PKG}.pick_next_game") as mock_pick,
|
|
patch(f"{PKG}.get_all_owned_app_ids", return_value=[]),
|
|
):
|
|
config = Config(steam_api_key="k", steam_id="i")
|
|
state = State()
|
|
cmd_pick(config, state)
|
|
mock_pick.assert_called_once()
|
|
|
|
def test_hides_games_after_pick(self) -> None:
|
|
snap = [_snap(2, "NewGame", 10, 0, 5.0)]
|
|
state = State(current_app_id=2, current_game_name="NewGame")
|
|
with (
|
|
patch(f"{PKG}.load_snapshot", return_value=snap),
|
|
patch(f"{PKG}.load_hltb_cache", return_value={2: 5.0}),
|
|
patch(f"{PKG}.pick_next_game"),
|
|
patch(f"{PKG}.get_all_owned_app_ids", return_value=[1, 2, 3]),
|
|
patch(f"{PKG}.hide_other_games", return_value=2) as mock_hide,
|
|
patch(f"{PKG}._echo"),
|
|
):
|
|
cmd_pick(Config(steam_api_key="k", steam_id="i"), state)
|
|
mock_hide.assert_called_once_with([1, 2, 3], 2)
|
|
|
|
def test_no_hide_message_when_none_hidden(self) -> None:
|
|
snap = [_snap(2, "NewGame", 10, 0, 5.0)]
|
|
state = State(current_app_id=2, current_game_name="NewGame")
|
|
with (
|
|
patch(f"{PKG}.load_snapshot", return_value=snap),
|
|
patch(f"{PKG}.load_hltb_cache", return_value={}),
|
|
patch(f"{PKG}.pick_next_game"),
|
|
patch(f"{PKG}.get_all_owned_app_ids", return_value=[1, 2, 3]),
|
|
patch(f"{PKG}.hide_other_games", return_value=0),
|
|
patch(f"{PKG}._echo") as mock_echo,
|
|
):
|
|
cmd_pick(Config(steam_api_key="k", steam_id="i"), state)
|
|
mock_echo.assert_not_called()
|
|
|
|
def test_no_hide_when_no_current_app(self) -> None:
|
|
snap = [_snap(2, "NewGame", 10, 0, 5.0)]
|
|
with (
|
|
patch(f"{PKG}.load_snapshot", return_value=snap),
|
|
patch(f"{PKG}.load_hltb_cache", return_value={}),
|
|
patch(f"{PKG}.pick_next_game"),
|
|
patch(f"{PKG}.get_all_owned_app_ids") as mock_owned,
|
|
):
|
|
cmd_pick(Config(steam_api_key="k", steam_id="i"), State())
|
|
mock_owned.assert_not_called()
|
|
|
|
def test_no_hide_when_owned_ids_empty(self) -> None:
|
|
snap = [_snap(2, "NewGame", 10, 0, 5.0)]
|
|
state = State(current_app_id=2, current_game_name="NewGame")
|
|
with (
|
|
patch(f"{PKG}.load_snapshot", return_value=snap),
|
|
patch(f"{PKG}.load_hltb_cache", return_value={}),
|
|
patch(f"{PKG}.pick_next_game"),
|
|
patch(f"{PKG}.get_all_owned_app_ids", return_value=[]),
|
|
patch(f"{PKG}.hide_other_games") as mock_hide,
|
|
):
|
|
cmd_pick(Config(steam_api_key="k", steam_id="i"), state)
|
|
mock_hide.assert_not_called()
|
|
|
|
def test_hltb_cache_applied_to_games(self) -> None:
|
|
snap = [_snap(2, "NewGame", 10, 0, -1.0)]
|
|
captured_games: list[list[GameInfo]] = []
|
|
config = Config(steam_api_key="k", steam_id="i")
|
|
state = State()
|
|
|
|
def capture_pick(games: list[GameInfo], *_args: object) -> None:
|
|
captured_games.append(list(games))
|
|
|
|
with (
|
|
patch(f"{PKG}.load_snapshot", return_value=snap),
|
|
patch(f"{PKG}.load_hltb_cache", return_value={2: 7.5}),
|
|
patch(f"{PKG}.pick_next_game", side_effect=capture_pick),
|
|
patch(f"{PKG}.get_all_owned_app_ids", return_value=[]),
|
|
):
|
|
cmd_pick(config, state)
|
|
|
|
assert len(captured_games) == 1
|
|
assert captured_games[0][0].completionist_hours == pytest.approx(7.5)
|