steam-backlog-enforcer/steam_backlog_enforcer/tests/test_release_lifecycle.py
Krzysztof kuhy Rudnicki 1bdf360193 pick keeps already-picked games; fast install reports progress
pick and pick-manual no longer uninstall games that are already allowed:
the outgoing assignment is kept as a released pick (installed, no lock)
until 'pick --replace', done/check, or abandon-pick. The allowed set is
capped at max_manual_picks + 1; a pick over it is refused before the
candidate list / confirmation, naming the games. status and the web
Picks card list kept games with an Abandon button.

Fast install now tracks the installer log from the spawn offset: web
jobs drive the progress bar (per-depot %, phase) and echo a line every
30 s; daemon-started runs are exposed via ctl status / /api/daemon and
shown on the Dashboard.

Also: web deps bumped for dependency freshness (react-query 5.104.5,
typescript-eslint 8.72.0, vite 8.3.5, node 24.22.1 in CI).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CUaX4zAa6H4sMu3jd6RFdb
2026-10-23 20:14:12 +02:00

212 lines
8.6 KiB
Python

"""A released game stays playable until the user explicitly picks another.
Covers the state upgrade, the allowed set, abandoning, pick order, tampering
and the ``check`` command under the "one new achievement" rule.
"""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
import json
from unittest.mock import MagicMock, patch
from steam_backlog_enforcer import config as config_mod
from steam_backlog_enforcer._actions import (
allowed_app_ids,
apply_manual_pick,
is_manual_pick_locked,
)
from steam_backlog_enforcer._allowed_games import drop_inactive_picks
from steam_backlog_enforcer._manual_pick_lifecycle import abandon_manual_pick
from steam_backlog_enforcer._scanning_assign import _open_candidates
from steam_backlog_enforcer._scanning_tampering import _check_game_tampering
from steam_backlog_enforcer.config import Config, State
from steam_backlog_enforcer.scanning import do_check
from steam_backlog_enforcer.steam_api import AchievementInfo, GameInfo
from steam_backlog_enforcer.tests._main_helpers import EXPIRED_AT, STARTED_AT
RELEASED = datetime(2026, 9, 20, tzinfo=UTC)
def pick(app_id: int, **extra: str) -> dict[str, object]:
"""Build one manual-pick entry started a day ago."""
return {
"app_id": app_id,
"game_name": f"G{app_id}",
"started_at": STARTED_AT,
} | dict(extra.items())
def game(app_id: int, *unlock_times: int, hours: float = 5.0) -> GameInfo:
"""Build a GameInfo with one unlocked achievement per epoch given."""
return GameInfo(
app_id=app_id,
name=f"G{app_id}",
total_achievements=10,
unlocked_achievements=len(unlock_times),
playtime_minutes=60,
completionist_hours=hours,
achievements=[
AchievementInfo(f"a{t}", f"A{t}", achieved=True, unlock_time=t)
for t in unlock_times
],
)
def write_state(data: dict[str, object]) -> None:
"""Write raw JSON to the (conftest-redirected) state file."""
config_mod.STATE_FILE.write_text(json.dumps(data), encoding="utf-8")
class TestLoadBackfill:
def test_plain_assignment_is_stamped_once_and_persisted(self) -> None:
write_state({"current_app_id": 5, "current_game_name": "G5"})
first = State.load()
assert first.current_assigned_at
assert State.load().current_assigned_at == first.current_assigned_at
assert first.last_assigned_at == {"5": first.current_assigned_at}
def test_current_pick_keeps_its_pick_time(self) -> None:
write_state({"current_app_id": 5, "manual_picks": [pick(5), pick(6)]})
state = State.load()
assert state.current_assigned_at == STARTED_AT
assert state.last_assigned_at == {"5": STARTED_AT, "6": STARTED_AT}
def test_legacy_pick_migration_is_persisted(self) -> None:
write_state({"manual_pick_app_id": 9, "manual_pick_started_at": ""})
State.load()
saved = json.loads(config_mod.STATE_FILE.read_text(encoding="utf-8"))
assert saved["manual_picks"][0]["app_id"] == 9
assert saved["manual_pick_app_id"] is None
def test_up_to_date_state_is_not_rewritten(self) -> None:
write_state({"current_app_id": None})
with patch.object(State, "save") as mock_save:
State.load()
mock_save.assert_not_called()
class TestReleasedPicksStayPlayable:
def test_released_and_expired_picks_stay_allowed_but_unlocked(self) -> None:
state = State(
current_app_id=1,
manual_picks=[
pick(2, released_at=STARTED_AT),
pick(3) | {"started_at": EXPIRED_AT},
],
)
assert allowed_app_ids(state) == {1, 2, 3}
assert is_manual_pick_locked(state) is False
def test_explicit_choice_drops_only_inactive_picks(self) -> None:
state = State(manual_picks=[pick(2, released_at=STARTED_AT), pick(3)])
dropped = drop_inactive_picks(state)
assert [p["app_id"] for p in dropped] == [2]
assert [p["app_id"] for p in state.manual_picks] == [3]
def test_new_manual_pick_keeps_released_ones(self) -> None:
state = State(current_app_id=2, manual_picks=[pick(2, released_at=STARTED_AT)])
with patch.object(State, "save"):
assert apply_manual_pick(state, 4, "G4", max_picks=1) is None
assert allowed_app_ids(state) == {2, 4}
assert state.current_assigned_at == state.manual_picks[-1]["started_at"]
def test_released_pick_can_be_abandoned(self) -> None:
state = State(
current_app_id=2, manual_picks=[pick(2, released_at="R"), pick(3)]
)
with patch.object(State, "save"):
assert abandon_manual_pick(state, 2) is True
# The active pick inherits the assignment, timed from its pick.
assert (state.current_app_id, state.current_assigned_at) == (3, STARTED_AT)
def test_abandoning_the_last_pick_clears_the_assignment(self) -> None:
state = State(current_app_id=2, current_assigned_at="T", manual_picks=[pick(2)])
with patch.object(State, "save"):
abandon_manual_pick(state, 2)
assert (state.current_app_id, state.current_assigned_at) == (None, "")
class TestPickOrder:
def test_least_recently_assigned_first_then_shortest(self) -> None:
state = State(
last_assigned_at={
"1": "2026-09-01T00:00:00+00:00",
"2": "2026-08-01T00:00:00+00:00",
}
)
games = [game(1, hours=1.0), game(2, hours=2.0), game(3, hours=9.0)]
with patch(
"steam_backlog_enforcer._scanning_assign"
"._apply_cached_confidence_to_candidates"
):
order = [g.app_id for g in _open_candidates(games, state)]
assert order == [3, 2, 1]
class TestTamperingAfterRelease:
def entry(self) -> dict[str, object]:
return {
"app_id": 7,
"name": "G7",
"total_achievements": 10,
"unlocked_achievements": 0,
"playtime_minutes": 60,
}
def check(self, *unlock_times: int) -> tuple[str, int, int] | None:
client = MagicMock()
client.refresh_single_game.return_value = game(7, *unlock_times)
state = State(current_app_id=1, released_at={"7": RELEASED.isoformat()})
return _check_game_tampering(client, self.entry(), state)
def test_unlocks_earned_while_assigned_are_legitimate(self) -> None:
before = int((RELEASED - timedelta(hours=1)).timestamp())
assert self.check(before, before) is None
def test_unlocks_after_release_are_flagged(self) -> None:
before = int((RELEASED - timedelta(hours=1)).timestamp())
after = int((RELEASED + timedelta(hours=1)).timestamp())
assert self.check(before, after) == ("G7", 7, 1)
def test_unreadable_game_is_not_flagged(self) -> None:
client = MagicMock()
client.refresh_single_game.return_value = None
assert _check_game_tampering(client, self.entry(), State()) is None
class TestCheckCommand:
def run_check(self, state: State, refreshed: GameInfo) -> MagicMock:
client = MagicMock()
client.refresh_single_game.return_value = refreshed
with (
patch(
"steam_backlog_enforcer.scanning.SteamAPIClient", return_value=client
),
patch("steam_backlog_enforcer.scanning._echo"),
patch("steam_backlog_enforcer.scanning.report_completion"),
patch("steam_backlog_enforcer.scanning.detect_tampering"),
patch("steam_backlog_enforcer.scanning.send_notification"),
patch("steam_backlog_enforcer.scanning.load_snapshot", return_value=[]),
patch("steam_backlog_enforcer.scanning.pick_next_game") as mock_pick,
patch.object(State, "save"),
):
do_check(Config(), state)
return mock_pick
def test_new_achievement_releases_and_keeps_the_game(self) -> None:
state = State(current_app_id=7, current_assigned_at=RELEASED.isoformat())
after = int((RELEASED + timedelta(hours=1)).timestamp())
self.run_check(state, game(7, after))
assert "7" in state.released_at
assert state.finished_app_ids == []
# No snapshot, so no replacement: the game stays assigned.
assert state.current_app_id == 7
def test_no_new_achievement_changes_nothing(self) -> None:
state = State(current_app_id=7, current_assigned_at=RELEASED.isoformat())
before = int((RELEASED - timedelta(hours=1)).timestamp())
mock_pick = self.run_check(state, game(7, before))
assert state.released_at == {}
mock_pick.assert_not_called()