mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-11-11 14:27:33 +01:00
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
212 lines
8.6 KiB
Python
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()
|