steam-backlog-enforcer/steam_backlog_enforcer/tests/test_release_lifecycle.py
Krzysztof kuhy Rudnicki a191c8e459 Release a game after one new achievement instead of 100% completion
Requiring 100% locked the user onto one game for weeks, which proved
ineffective. The assignment and every manual pick now release as soon as one
achievement is unlocked after the game was assigned (Steam unlock_time vs the
pick's started_at / State.current_assigned_at).

- finished_app_ids still means 100% complete; a game released below 100% gets
  released_at plus a 7-day cooldown and returns to the pool.
- Next pick: least recently assigned first, then shortest HLTB time.
- Releasing never evicts: released/expired picks stay installed and playable
  until the user explicitly chooses another game (new pick-manual, or
  accepting the next assignment); with no replacement the current game stays.
- Tampering detection judges a released game by unlocks after its release.
- The daemon reloads every State field each tick so its sweep save cannot
  overwrite cooldowns or assignment times written by the CLI.
- State.load backfills assignment times once and persists them; load-time
  migrations moved to _state_migrations.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PhzaTAWtRfJk8gre6NLSka
2026-09-26 16:46:03 +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_replaces_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) == {4}
assert state.current_assigned_at == state.manual_picks[0]["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()