mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-11-10 21:27:03 +01:00
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
182 lines
6.4 KiB
Python
182 lines
6.4 KiB
Python
"""Tests for the "earn one new achievement" release rule."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import UTC, datetime, timedelta
|
|
|
|
from steam_backlog_enforcer._assignment_progress import (
|
|
RELEASE_COOLDOWN_DAYS,
|
|
assignment_baseline,
|
|
iso_to_epoch,
|
|
last_assigned_epoch,
|
|
mark_finished,
|
|
newest_unlock_since,
|
|
record_assignment,
|
|
release_game,
|
|
release_verdict,
|
|
)
|
|
from steam_backlog_enforcer.config import State
|
|
from steam_backlog_enforcer.steam_api import AchievementInfo, GameInfo
|
|
|
|
ASSIGNED = datetime(2026, 9, 20, tzinfo=UTC)
|
|
BEFORE = int((ASSIGNED - timedelta(days=1)).timestamp())
|
|
AFTER = int((ASSIGNED + timedelta(days=1)).timestamp())
|
|
LATER = int((ASSIGNED + timedelta(days=2)).timestamp())
|
|
|
|
|
|
def game(*unlocks: tuple[str, int], total: int = 5, app_id: int = 1) -> GameInfo:
|
|
"""Build a GameInfo whose achievements unlocked at the given epochs.
|
|
|
|
Args:
|
|
unlocks: ``(display_name, unlock_time)`` per unlocked achievement.
|
|
total: Total achievement count (locked ones fill the rest).
|
|
app_id: Steam application id.
|
|
|
|
Returns:
|
|
The GameInfo.
|
|
"""
|
|
achievements = [
|
|
AchievementInfo(n, n, achieved=True, unlock_time=t) for n, t in unlocks
|
|
]
|
|
achievements += [
|
|
AchievementInfo(f"L{i}", f"L{i}", achieved=False, unlock_time=0)
|
|
for i in range(total - len(unlocks))
|
|
]
|
|
return GameInfo(
|
|
app_id=app_id,
|
|
name="G",
|
|
total_achievements=total,
|
|
unlocked_achievements=len(unlocks),
|
|
playtime_minutes=60,
|
|
achievements=achievements,
|
|
)
|
|
|
|
|
|
def assigned_state(app_id: int = 1) -> State:
|
|
"""State whose current game *app_id* was assigned at ``ASSIGNED``."""
|
|
return State(
|
|
current_app_id=app_id,
|
|
current_game_name="G",
|
|
current_assigned_at=ASSIGNED.isoformat(),
|
|
)
|
|
|
|
|
|
class TestIsoToEpoch:
|
|
def test_parses(self) -> None:
|
|
assert iso_to_epoch(ASSIGNED.isoformat()) == ASSIGNED.timestamp()
|
|
|
|
def test_empty_and_malformed_are_unknown(self) -> None:
|
|
assert iso_to_epoch("") is None
|
|
assert iso_to_epoch("not a date") is None
|
|
|
|
|
|
class TestNewestUnlockSince:
|
|
def test_picks_the_latest_unlock_after_the_baseline(self) -> None:
|
|
g = game(("old", BEFORE), ("a", AFTER), ("b", LATER))
|
|
newest = newest_unlock_since(g, ASSIGNED.isoformat())
|
|
assert newest is not None
|
|
assert newest.display_name == "b"
|
|
|
|
def test_unlocks_before_the_baseline_do_not_count(self) -> None:
|
|
assert newest_unlock_since(game(("old", BEFORE)), ASSIGNED.isoformat()) is None
|
|
|
|
def test_unknown_baseline_never_counts(self) -> None:
|
|
assert newest_unlock_since(game(("a", AFTER)), "") is None
|
|
|
|
|
|
class TestAssignmentBaseline:
|
|
def test_active_pick_uses_its_started_at(self) -> None:
|
|
state = assigned_state()
|
|
state.manual_picks = [{"app_id": 1, "game_name": "G", "started_at": "P"}]
|
|
assert assignment_baseline(state, 1) == "P"
|
|
|
|
def test_released_pick_falls_back_to_the_assignment(self) -> None:
|
|
state = assigned_state()
|
|
state.manual_picks = [
|
|
{"app_id": 1, "game_name": "G", "started_at": "P", "released_at": "R"}
|
|
]
|
|
assert assignment_baseline(state, 1) == ASSIGNED.isoformat()
|
|
|
|
def test_unassigned_game_has_no_baseline(self) -> None:
|
|
assert assignment_baseline(assigned_state(), 2) == ""
|
|
|
|
|
|
class TestReleaseVerdict:
|
|
def test_new_achievement_releases(self) -> None:
|
|
released, why = release_verdict(assigned_state(), game(("Win", AFTER)))
|
|
assert released is True
|
|
assert "NEW ACHIEVEMENT on G: 'Win'" in why
|
|
|
|
def test_complete_releases_even_without_a_new_unlock(self) -> None:
|
|
released, why = release_verdict(assigned_state(), game(("a", BEFORE), total=1))
|
|
assert released is True
|
|
assert "100%" in why
|
|
|
|
def test_no_new_achievement_keeps_the_game(self) -> None:
|
|
released, why = release_verdict(assigned_state(), game(("a", BEFORE)))
|
|
assert released is False
|
|
assert "No new achievement since 2026-09-20" in why
|
|
|
|
def test_no_baseline_is_named_generically(self) -> None:
|
|
released, why = release_verdict(State(), game())
|
|
assert released is False
|
|
assert "since its assignment" in why
|
|
|
|
|
|
class TestRecordAssignment:
|
|
def test_stamps_current_and_history(self) -> None:
|
|
state = State()
|
|
record_assignment(state, 7, "Seven", at="T")
|
|
assert (state.current_app_id, state.current_game_name) == (7, "Seven")
|
|
assert state.current_assigned_at == "T"
|
|
assert state.last_assigned_at == {"7": "T"}
|
|
assert state.enforcement_started_at == "T"
|
|
|
|
def test_keeps_the_first_enforcement_start(self) -> None:
|
|
state = State(enforcement_started_at="FIRST")
|
|
record_assignment(state, 7, "Seven")
|
|
assert state.enforcement_started_at == "FIRST"
|
|
assert iso_to_epoch(state.current_assigned_at) is not None
|
|
|
|
|
|
class TestLastAssignedEpoch:
|
|
def test_never_assigned_sorts_first(self) -> None:
|
|
assert last_assigned_epoch(State(), 1) == 0.0
|
|
|
|
def test_reads_the_stamp(self) -> None:
|
|
state = State(last_assigned_at={"1": ASSIGNED.isoformat()})
|
|
assert last_assigned_epoch(state, 1) == ASSIGNED.timestamp()
|
|
|
|
|
|
class TestMarkFinished:
|
|
def test_records_once(self) -> None:
|
|
state = State()
|
|
assert mark_finished(state, 7) is True
|
|
assert mark_finished(state, 7) is False
|
|
assert state.finished_app_ids == [7]
|
|
|
|
|
|
class TestReleaseGame:
|
|
def test_below_100_is_not_finished_but_cools_down(self) -> None:
|
|
state = assigned_state()
|
|
state.manual_picks = [{"app_id": 1, "game_name": "G", "started_at": "P"}]
|
|
release_game(state, game(("a", AFTER)))
|
|
assert state.finished_app_ids == []
|
|
assert state.manual_picks[0]["released_at"]
|
|
assert "1" in state.released_at
|
|
expiry = datetime.fromisoformat(state.skipped_until["1"])
|
|
assert expiry > datetime.now(UTC) + timedelta(days=RELEASE_COOLDOWN_DAYS - 1)
|
|
|
|
def test_complete_game_is_finished(self) -> None:
|
|
state = assigned_state()
|
|
release_game(state, game(("a", AFTER), total=1))
|
|
assert state.finished_app_ids == [1]
|
|
|
|
def test_keeps_an_earlier_pick_release_time(self) -> None:
|
|
state = assigned_state()
|
|
state.manual_picks = [
|
|
{"app_id": 1, "game_name": "G", "started_at": "P", "released_at": "R"}
|
|
]
|
|
release_game(state, game(("a", AFTER)))
|
|
assert state.manual_picks[0]["released_at"] == "R"
|