diff --git a/steam_backlog_enforcer/_actions.py b/steam_backlog_enforcer/_actions.py index d139ef0..8e3974f 100644 --- a/steam_backlog_enforcer/_actions.py +++ b/steam_backlog_enforcer/_actions.py @@ -27,11 +27,6 @@ from steam_backlog_enforcer._allowed_games import ( if TYPE_CHECKING: from steam_backlog_enforcer.config import State -# Mistake-correction window: for this many days after a manual pick the user -# may still back out via ``abandon-pick``. Deliberately short — it exists to -# undo a wrong pick, not to serve as an escape hatch from the lock. -MANUAL_GRACE_DAYS = 4 - # How long an abandoned pick stays out of the auto-assignment pool, so that # ``scan`` does not immediately hand back the game the user just rejected. ABANDON_COOLDOWN_DAYS = 30 @@ -157,8 +152,7 @@ def apply_manual_pick( _MOVED_TO_MANUAL_PICK_LIFECYCLE = frozenset( { "abandon_manual_pick", - "can_abandon_manual_pick", - "manual_pick_grace_remaining", + "manual_pick_age_days", "status_payload", } ) diff --git a/steam_backlog_enforcer/_manual_pick_lifecycle.py b/steam_backlog_enforcer/_manual_pick_lifecycle.py index 8d89bf6..9a39234 100644 --- a/steam_backlog_enforcer/_manual_pick_lifecycle.py +++ b/steam_backlog_enforcer/_manual_pick_lifecycle.py @@ -1,4 +1,4 @@ -"""Grace periods, abandonment and the status payload for manual picks. +"""Abandonment and the status payload for manual picks. Split out of :mod:`steam_backlog_enforcer._actions` to keep both files under the 250-line cap. @@ -6,7 +6,7 @@ under the 250-line cap. from __future__ import annotations -from datetime import datetime, timedelta, timezone +from datetime import datetime, timezone from typing import TYPE_CHECKING, Any from steam_backlog_enforcer._actions import ( @@ -34,30 +34,25 @@ __all__ = [ if TYPE_CHECKING: from steam_backlog_enforcer.config import State -# Mistake-correction window: for this many days after a manual pick the user -# may still back out via ``abandon-pick``. Deliberately short — it exists to -# undo a wrong pick, not to serve as an escape hatch from the lock. -MANUAL_GRACE_DAYS = 4 - # How long an abandoned pick stays out of the auto-assignment pool, so that # ``scan`` does not immediately hand back the game the user just rejected. ABANDON_COOLDOWN_DAYS = 30 -def manual_pick_grace_remaining(state: State, app_id: int) -> float | None: - """Return days left in *app_id*'s grace window, or ``None``. +def manual_pick_age_days(state: State, app_id: int) -> float | None: + """Return how many days ago *app_id* was picked, or ``None``. - ``None`` means "no grace window applies": *app_id* is not an active manual - pick, or its timestamp is missing/malformed. A value <= 0 means the window - has closed. + Informational only — picks can be abandoned at any age. ``None`` means the + question does not apply: *app_id* is not an active manual pick, or its + timestamp is missing/malformed. Args: state: The loaded enforcer state. app_id: The manually-picked app id to measure. Returns: - Fractional days remaining before the pick can no longer be abandoned, - or ``None`` when the question does not apply. + Fractional days elapsed since the pick was made, or ``None`` when the + question does not apply. """ pick = find_manual_pick(state, app_id) if pick is None or not pick.get("started_at"): @@ -66,26 +61,13 @@ def manual_pick_grace_remaining(state: State, app_id: int) -> float | None: started = datetime.fromisoformat(pick["started_at"]) except ValueError: return None - deadline = started + timedelta(days=MANUAL_GRACE_DAYS) - return (deadline - datetime.now(timezone.utc)).total_seconds() / 86400 - - -def can_abandon_manual_pick(state: State, app_id: int) -> bool: - """Return ``True`` if *app_id* is still inside its grace window. - - Args: - state: The loaded enforcer state. - app_id: The manually-picked app id to check. - - Returns: - Whether ``abandon_manual_pick`` would be accepted right now. - """ - remaining = manual_pick_grace_remaining(state, app_id) - return remaining is not None and remaining > 0 + return (datetime.now(timezone.utc) - started).total_seconds() / 86400 def abandon_manual_pick(state: State, app_id: int) -> bool: - """Drop one manual pick and persist ``state``, if still inside its grace window. + """Drop one manual pick and persist ``state``. + + A pick may be abandoned at any time, however long ago it was made. Only the named pick is dropped: any other active pick keeps its own lock and deadline. The abandoned app id goes onto the existing skip cooldown so @@ -98,11 +80,10 @@ def abandon_manual_pick(state: State, app_id: int) -> bool: app_id: The manually-picked app id to back out of. Returns: - ``True`` if the pick was abandoned, ``False`` if its grace window has - closed (or it is not an active pick), in which case ``state`` is - untouched. + ``True`` if the pick was abandoned, ``False`` if it is not an active + pick, in which case ``state`` is untouched. """ - if not can_abandon_manual_pick(state, app_id): + if find_manual_pick(state, app_id) is None: return False state.skip_for_days(app_id, ABANDON_COOLDOWN_DAYS) @@ -159,7 +140,7 @@ def status_payload(state: State) -> dict[str, Any]: "manual_picks": [ { **pick, - "grace_days_left": manual_pick_grace_remaining(state, pick["app_id"]), + "age_days": manual_pick_age_days(state, pick["app_id"]), } for pick in active_manual_picks(state) ], diff --git a/steam_backlog_enforcer/_mcp_actions.py b/steam_backlog_enforcer/_mcp_actions.py index 23bda1f..4ab20b4 100644 --- a/steam_backlog_enforcer/_mcp_actions.py +++ b/steam_backlog_enforcer/_mcp_actions.py @@ -11,13 +11,11 @@ from typing import Any from steam_backlog_enforcer._actions import ( ABANDON_COOLDOWN_DAYS, - MANUAL_GRACE_DAYS, abandon_manual_pick, active_manual_picks, apply_manual_pick, - can_abandon_manual_pick, find_manual_pick, - manual_pick_grace_remaining, + manual_pick_age_days, ) from steam_backlog_enforcer._allowed_games import MANUAL_LOCK_DAYS from steam_backlog_enforcer._mcp_server import ( @@ -84,11 +82,11 @@ def pick_manual(app_id: int, *, confirm: bool = False) -> dict[str, Any]: @mcp.tool() def abandon_pick(app_id: int, *, confirm: bool = False) -> dict[str, Any]: - """Undo a manual pick inside its grace window (gated write). + """Undo a manual pick (gated write). With ``confirm=False`` (the default) this performs **no** mutation and - returns a preview. Only works within ``MANUAL_GRACE_DAYS`` days of the - pick. Like ``pick_manual``, this mutates **state only** — the CLI's + returns a preview. A pick can be abandoned at any time, however long ago + it was made. Like ``pick_manual``, this mutates **state only** — the CLI's ``abandon-pick`` additionally uninstalls the abandoned game. Args: @@ -110,15 +108,7 @@ def abandon_pick(app_id: int, *, confirm: bool = False) -> dict[str, Any]: ), } - remaining = manual_pick_grace_remaining(state, app_id) - if not can_abandon_manual_pick(state, app_id): - return { - "ok": False, - "reason": ( - f"The {MANUAL_GRACE_DAYS}-day grace period has expired; " - "the pick can no longer be abandoned." - ), - } + age = manual_pick_age_days(state, app_id) game_name = str(pick["game_name"]) if not confirm: @@ -128,7 +118,7 @@ def abandon_pick(app_id: int, *, confirm: bool = False) -> dict[str, Any]: "action": "abandon_pick", "app_id": app_id, "game_name": game_name, - "grace_days_left": remaining, + "age_days": age, "effect": ( "Releases the manual pick lock, clears the assignment, and " f"keeps the game out of auto-assignment for " diff --git a/steam_backlog_enforcer/config.py b/steam_backlog_enforcer/config.py index 51af29f..4a9d119 100644 --- a/steam_backlog_enforcer/config.py +++ b/steam_backlog_enforcer/config.py @@ -60,7 +60,7 @@ class Config: kill_unauthorized_games: bool = True uninstall_other_games: bool = True desktop_notifications: bool = True - max_manual_picks: int = 3 + max_manual_picks: int = 2 """How many games may be manually locked in at once. All active picks stay installed and visible; the enforcer treats them as diff --git a/steam_backlog_enforcer/main/__init__.py b/steam_backlog_enforcer/main/__init__.py index 136f544..c9b03ed 100644 --- a/steam_backlog_enforcer/main/__init__.py +++ b/steam_backlog_enforcer/main/__init__.py @@ -13,7 +13,6 @@ import logging import sys from typing import TYPE_CHECKING -from steam_backlog_enforcer._actions import MANUAL_GRACE_DAYS as _MANUAL_GRACE_DAYS from steam_backlog_enforcer._allowed_games import ( MANUAL_LOCK_DAYS as _MANUAL_LOCK_DAYS, ) @@ -84,7 +83,6 @@ logger = logging.getLogger(__name__) # before it became a package, and the test-suite reaches for them by name. __all__ = [ "COMMANDS", - "_MANUAL_GRACE_DAYS", "_MANUAL_LOCK_DAYS", "_MANUAL_LOCK_EXEMPT_COMMANDS", "_TOTAL_BLOCK_EXEMPT_COMMANDS", @@ -145,9 +143,7 @@ COMMANDS: dict[str, tuple[str, Callable[[Config, State], object]]] = { _EXTRA_COMMAND_DESCRIPTIONS: dict[str, str] = { "add-exception": "Request 24h-locked whitelist exception (use --reason)", "pick-manual": f"Pick a game by app_id, lock enforcer for {_MANUAL_LOCK_DAYS} days", - "abandon-pick": ( - f"Undo a manual pick within {_MANUAL_GRACE_DAYS} days (needs app_id)" - ), + "abandon-pick": "Undo a manual pick at any time (needs app_id)", "block-gaming": "Block ALL gaming for days, no in-app undo", "enforce": "Run enforcer: block, uninstall, kill, hide (--demo for a 60s budget)", "gaming-unblock": "Force-release playtime bind mounts (root; recovery hatch)", diff --git a/steam_backlog_enforcer/main/_shared.py b/steam_backlog_enforcer/main/_shared.py index 40d285e..3f4a06e 100644 --- a/steam_backlog_enforcer/main/_shared.py +++ b/steam_backlog_enforcer/main/_shared.py @@ -11,12 +11,9 @@ from datetime import datetime, timedelta, timezone import sys from typing import TYPE_CHECKING -from steam_backlog_enforcer._actions import ( - MANUAL_GRACE_DAYS as _MANUAL_GRACE_DAYS, -) from steam_backlog_enforcer._actions import ( active_manual_picks, - manual_pick_grace_remaining, + manual_pick_age_days, ) from steam_backlog_enforcer._actions import ( is_manual_pick_locked as _is_manual_pick_locked, @@ -140,11 +137,11 @@ def _describe_pick(state: State, pick: dict[str, object]) -> bool: except ValueError: pass - grace_left = manual_pick_grace_remaining(state, int(str(app_id))) - if grace_left is not None and grace_left > 0: + age_days = manual_pick_age_days(state, int(str(app_id))) + if age_days is not None: _echo( f" Undo: abandon-pick {app_id}" - f" ({grace_left:.1f} of {_MANUAL_GRACE_DAYS} grace day(s) left)" + f" (picked {age_days:.1f} day(s) ago)" ) return True return False diff --git a/steam_backlog_enforcer/main/abandon.py b/steam_backlog_enforcer/main/abandon.py index 3ca1a6a..ac271e6 100644 --- a/steam_backlog_enforcer/main/abandon.py +++ b/steam_backlog_enforcer/main/abandon.py @@ -1,4 +1,4 @@ -"""``abandon-pick``: back out of a manual pick inside its grace window.""" +"""``abandon-pick``: back out of a manual pick.""" from __future__ import annotations @@ -8,15 +8,10 @@ from typing import TYPE_CHECKING from steam_backlog_enforcer._actions import ( ABANDON_COOLDOWN_DAYS as _ABANDON_COOLDOWN_DAYS, ) -from steam_backlog_enforcer._actions import ( - MANUAL_GRACE_DAYS as _MANUAL_GRACE_DAYS, -) from steam_backlog_enforcer._actions import ( abandon_manual_pick, active_manual_picks, - can_abandon_manual_pick, find_manual_pick, - manual_pick_grace_remaining, ) from steam_backlog_enforcer.game_install import ( _echo, @@ -30,7 +25,7 @@ if TYPE_CHECKING: _ABANDON_PICK_USAGE = ( "Usage: abandon-pick \n" " app_id : the manually-picked game you want to back out of.\n\n" - f"Only works within {_MANUAL_GRACE_DAYS} days of the pick." + "A pick can be abandoned at any time, however long ago it was made." ) @@ -74,7 +69,7 @@ def _abandon_pick_target(state: State, args: list[str]) -> int | None: def cmd_abandon_pick(_config: Config, state: State, args: list[str]) -> None: - """Back out of a manual pick while still inside the grace period. + """Back out of a manual pick, whatever its age. Args: _config: Enforcer configuration (unused, kept for dispatch symmetry). @@ -88,22 +83,6 @@ def cmd_abandon_pick(_config: Config, state: State, args: list[str]) -> None: pick = find_manual_pick(state, app_id) game_name = str(pick["game_name"]) if pick else "" - if not can_abandon_manual_pick(state, app_id): - # ``remaining`` is negative once the window has closed, so its - # magnitude is how long ago that happened. - remaining = manual_pick_grace_remaining(state, app_id) - elapsed = ( - f"{abs(remaining):.1f} day(s) ago" - if remaining is not None - else "at an unknown time" - ) - _echo( - f"\nGrace period EXPIRED for {game_name} (AppID={app_id}).\n" - f"The {_MANUAL_GRACE_DAYS}-day window closed {elapsed}.\n" - "Finish the game (100% achievements) or wait out the lock." - ) - sys.exit(1) - others = [p for p in active_manual_picks(state) if p["app_id"] != app_id] _echo(f"\nAbandoning manual pick: {game_name} (AppID={app_id})") _echo( @@ -124,10 +103,7 @@ def cmd_abandon_pick(_config: Config, state: State, args: list[str]) -> None: _echo("Aborted.") return - if not abandon_manual_pick(state, app_id): - # Should be unreachable: the grace check above already passed. - _echo("Error: the grace period closed before the pick could be abandoned.") - sys.exit(1) + abandon_manual_pick(state, app_id) _echo(f"\nManual pick abandoned: {game_name}") diff --git a/steam_backlog_enforcer/main/picks.py b/steam_backlog_enforcer/main/picks.py index f742aba..c35cd55 100644 --- a/steam_backlog_enforcer/main/picks.py +++ b/steam_backlog_enforcer/main/picks.py @@ -5,9 +5,6 @@ from __future__ import annotations import sys from typing import TYPE_CHECKING -from steam_backlog_enforcer._actions import ( - MANUAL_GRACE_DAYS as _MANUAL_GRACE_DAYS, -) from steam_backlog_enforcer._actions import ( active_manual_picks, allowed_app_ids, @@ -109,8 +106,8 @@ def _report_pick_slots(config: Config, state: State) -> list[dict[str, object]]: if manual_pick_slots_left(state, config.max_manual_picks) == 0: _echo( f"\nError: you already have {config.max_manual_picks} manual pick(s)." - f"\nFinish one, or undo one with 'abandon-pick ' while it is" - f"\nstill inside its {_MANUAL_GRACE_DAYS}-day grace window." + f"\nFinish one, or undo one with 'abandon-pick ' — a pick" + f"\ncan be abandoned at any time." ) sys.exit(1) @@ -190,8 +187,7 @@ def cmd_pick_manual(config: Config, state: State, args: list[str]) -> None: f"\n you reach 100% achievements on every pick" f"\n - Leave only these commands usable:" f"\n {', '.join(sorted(_MANUAL_LOCK_EXEMPT_COMMANDS))}" - f"\n - Stay undoable via 'abandon-pick {app_id}' for the first" - f"\n {_MANUAL_GRACE_DAYS} days" + f"\n - Stay undoable at any time via 'abandon-pick {app_id}'" ) _echo() confirm = input( diff --git a/steam_backlog_enforcer/main/status.py b/steam_backlog_enforcer/main/status.py index 1867362..118da2d 100644 --- a/steam_backlog_enforcer/main/status.py +++ b/steam_backlog_enforcer/main/status.py @@ -6,7 +6,7 @@ from typing import TYPE_CHECKING from steam_backlog_enforcer._actions import ( active_manual_picks, - manual_pick_grace_remaining, + manual_pick_age_days, ) from steam_backlog_enforcer._total_block import get_total_block_status from steam_backlog_enforcer.config import load_snapshot @@ -57,13 +57,9 @@ def cmd_status(_config: Config, state: State) -> None: if picks: _echo(f"\nManual picks ({len(picks)}):") for pick in picks: - grace = manual_pick_grace_remaining(state, pick["app_id"]) - undo = ( - f" — undoable for {grace:.1f} more day(s)" - if grace is not None and grace > 0 - else "" - ) - _echo(f" {pick['game_name']} (AppID={pick['app_id']}){undo}") + age = manual_pick_age_days(state, pick["app_id"]) + since = f" — picked {age:.1f} day(s) ago" if age is not None else "" + _echo(f" {pick['game_name']} (AppID={pick['app_id']}){since}") _echo("\n[MANUAL PICK LOCK is active — most commands are blocked]") diff --git a/steam_backlog_enforcer/tests/_main_helpers.py b/steam_backlog_enforcer/tests/_main_helpers.py index 1255e00..bde94c7 100644 --- a/steam_backlog_enforcer/tests/_main_helpers.py +++ b/steam_backlog_enforcer/tests/_main_helpers.py @@ -9,7 +9,6 @@ from __future__ import annotations from datetime import datetime, timedelta, timezone from typing import Any -from steam_backlog_enforcer._actions import MANUAL_GRACE_DAYS from steam_backlog_enforcer._allowed_games import MANUAL_LOCK_DAYS from steam_backlog_enforcer._total_block import TotalBlockStatus from steam_backlog_enforcer.config import State @@ -107,15 +106,13 @@ def locked_state( ) -IN_GRACE = (datetime.now(timezone.utc) - timedelta(days=1)).isoformat() -PAST_GRACE = ( - datetime.now(timezone.utc) - timedelta(days=MANUAL_GRACE_DAYS + 1) -).isoformat() +RECENT_PICK = (datetime.now(timezone.utc) - timedelta(days=1)).isoformat() +OLD_PICK = (datetime.now(timezone.utc) - timedelta(days=8)).isoformat() VALID_REASON = "I need this game installed for a work presentation this week." -def abandonable_state(app_id: int = 100, started_at: str = IN_GRACE) -> State: +def abandonable_state(app_id: int = 100, started_at: str = RECENT_PICK) -> State: """Build a State whose single manual pick is still abandonable. Args: @@ -139,8 +136,8 @@ def two_pick_state() -> State: """ state = State( manual_picks=[ - {"app_id": 100, "game_name": "TestGame", "started_at": IN_GRACE}, - {"app_id": 200, "game_name": "SecondGame", "started_at": IN_GRACE}, + {"app_id": 100, "game_name": "TestGame", "started_at": RECENT_PICK}, + {"app_id": 200, "game_name": "SecondGame", "started_at": RECENT_PICK}, ], ) state.current_app_id = 200 diff --git a/steam_backlog_enforcer/tests/test_actions.py b/steam_backlog_enforcer/tests/test_actions.py index 96bd197..9c26cb1 100644 --- a/steam_backlog_enforcer/tests/test_actions.py +++ b/steam_backlog_enforcer/tests/test_actions.py @@ -6,12 +6,11 @@ from datetime import datetime, timedelta, timezone import pytest -from steam_backlog_enforcer import _actions, _allowed_games +from steam_backlog_enforcer import _allowed_games from steam_backlog_enforcer._actions import ( apply_manual_pick, - can_abandon_manual_pick, is_manual_pick_locked, - manual_pick_grace_remaining, + manual_pick_age_days, ) from steam_backlog_enforcer.config import State @@ -151,22 +150,22 @@ class TestApplyManualPick: assert apply_manual_pick(state, 620, "Portal 2") is not None -class TestManualPickGraceRemaining: - """Tests for Manual Pick Grace Remaining.""" +class TestManualPickAgeDays: + """Tests for Manual Pick Age Days.""" def test_no_pick_returns_none(self) -> None: """Test no pick returns none.""" - assert manual_pick_grace_remaining(State(), 440) is None + assert manual_pick_age_days(State(), 440) is None def test_unknown_app_id_returns_none(self) -> None: """Test unknown app id returns none.""" state = State(manual_picks=[_pick(440)]) - assert manual_pick_grace_remaining(state, 999) is None + assert manual_pick_age_days(state, 999) is None def test_missing_timestamp_returns_none(self) -> None: """Test missing timestamp returns none.""" state = State(manual_picks=[{"app_id": 440, "game_name": "TF2"}]) - assert manual_pick_grace_remaining(state, 440) is None + assert manual_pick_age_days(state, 440) is None def test_malformed_timestamp_returns_none(self) -> None: """Test malformed timestamp returns none.""" @@ -175,36 +174,18 @@ class TestManualPickGraceRemaining: {"app_id": 440, "game_name": "TF2", "started_at": "not-a-date"} ] ) - assert manual_pick_grace_remaining(state, 440) is None + assert manual_pick_age_days(state, 440) is None - def test_fresh_pick_has_almost_the_full_window(self) -> None: - """Test fresh pick has almost the full window.""" + def test_fresh_pick_is_almost_zero_days_old(self) -> None: + """Test fresh pick is almost zero days old.""" state = State(manual_picks=[_pick(days_ago=0)]) - remaining = manual_pick_grace_remaining(state, 440) - assert remaining is not None - assert remaining == pytest.approx(_actions.MANUAL_GRACE_DAYS, abs=0.01) + age = manual_pick_age_days(state, 440) + assert age is not None + assert age == pytest.approx(0.0, abs=0.01) - def test_expired_window_is_negative(self) -> None: - """Test expired window is negative.""" - state = State(manual_picks=[_pick(days_ago=_actions.MANUAL_GRACE_DAYS + 1)]) - remaining = manual_pick_grace_remaining(state, 440) - assert remaining is not None - assert remaining == pytest.approx(-1.0, abs=0.01) - - -class TestCanAbandonManualPick: - """Tests for Can Abandon Manual Pick.""" - - def test_inside_window(self) -> None: - """Test inside window.""" - state = State(manual_picks=[_pick(days_ago=_actions.MANUAL_GRACE_DAYS - 1)]) - assert can_abandon_manual_pick(state, 440) is True - - def test_outside_window(self) -> None: - """Test outside window.""" - state = State(manual_picks=[_pick(days_ago=_actions.MANUAL_GRACE_DAYS + 1)]) - assert can_abandon_manual_pick(state, 440) is False - - def test_no_pick(self) -> None: - """Test no pick.""" - assert can_abandon_manual_pick(State(), 440) is False + def test_old_pick_reports_its_age(self) -> None: + """Test old pick reports its age.""" + state = State(manual_picks=[_pick(days_ago=8)]) + age = manual_pick_age_days(state, 440) + assert age is not None + assert age == pytest.approx(8.0, abs=0.01) diff --git a/steam_backlog_enforcer/tests/test_actions_abandon_manual_pick.py b/steam_backlog_enforcer/tests/test_actions_abandon_manual_pick.py index e9c04c4..8a42c31 100644 --- a/steam_backlog_enforcer/tests/test_actions_abandon_manual_pick.py +++ b/steam_backlog_enforcer/tests/test_actions_abandon_manual_pick.py @@ -66,12 +66,18 @@ class TestAbandonManualPick: abandon_manual_pick(self._state(), 440) assert State.load().manual_picks == [] - def test_refuses_after_grace_and_leaves_state_untouched(self) -> None: - """Test refuses after grace and leaves state untouched.""" - state = self._state( - picks=[_pick(days_ago=_actions.MANUAL_GRACE_DAYS + 1)], - ) - assert abandon_manual_pick(state, 440) is False + def test_abandons_a_pick_well_past_the_old_grace_window(self) -> None: + """Test abandons a pick well past the old grace window.""" + # 8 days: long past the deleted 4-day grace window, still inside the + # 14-day lock, so the pick is active and must remain abandonable. + state = self._state(picks=[_pick(days_ago=8)]) + assert abandon_manual_pick(state, 440) is True + assert state.manual_picks == [] + + def test_refuses_unknown_app_id_and_leaves_state_untouched(self) -> None: + """Test refuses unknown app id and leaves state untouched.""" + state = self._state() + assert abandon_manual_pick(state, 999) is False assert [p["app_id"] for p in state.manual_picks] == [440] assert state.current_app_id == 440 assert state.skipped_until == {} diff --git a/steam_backlog_enforcer/tests/test_main_abandon.py b/steam_backlog_enforcer/tests/test_main_abandon.py index a10fb1d..73d7f04 100644 --- a/steam_backlog_enforcer/tests/test_main_abandon.py +++ b/steam_backlog_enforcer/tests/test_main_abandon.py @@ -16,8 +16,8 @@ from steam_backlog_enforcer.main import ( main, ) from steam_backlog_enforcer.tests._main_helpers import ( - IN_GRACE, - PAST_GRACE, + OLD_PICK, + RECENT_PICK, abandonable_state, locked_state, two_pick_state, @@ -50,23 +50,29 @@ class TestCmdAbandonPick: str(c) for c in mock_echo.call_args_list ) - def test_expired_grace_exits_without_mutating(self) -> None: - state = abandonable_state(started_at=PAST_GRACE) + def test_old_pick_can_still_be_abandoned(self) -> None: + """A pick has no grace window: however old, it can be abandoned.""" + state = abandonable_state(started_at=OLD_PICK) with ( - patch(f"{PKG}._echo") as mock_echo, - patch.object(State, "save") as mock_save, - pytest.raises(SystemExit) as exc, + patch(f"{PKG}._echo"), + patch(f"{PKG}.input", return_value="YES"), + patch(f"{PKG}.is_game_installed", return_value=False), + patch.object(State, "save"), ): cmd_abandon_pick(Config(), state, ["100"]) - assert exc.value.code == 1 - assert "EXPIRED" in " ".join(str(c) for c in mock_echo.call_args_list) - mock_save.assert_not_called() - assert [p["app_id"] for p in state.manual_picks] == [100] + assert state.manual_picks == [] - def test_expired_grace_with_bad_timestamp_still_exits(self) -> None: + def test_bad_timestamp_pick_can_still_be_abandoned(self) -> None: + """An unparsable started_at does not block abandoning.""" state = abandonable_state(started_at="not-a-date") - with patch(f"{PKG}._echo"), pytest.raises(SystemExit): + with ( + patch(f"{PKG}._echo"), + patch(f"{PKG}.input", return_value="YES"), + patch(f"{PKG}.is_game_installed", return_value=False), + patch.object(State, "save"), + ): cmd_abandon_pick(Config(), state, ["100"]) + assert state.manual_picks == [] def test_aborted_when_not_yes(self) -> None: state = abandonable_state() @@ -121,22 +127,6 @@ class TestCmdAbandonPick: cmd_abandon_pick(Config(), state, ["100"]) assert "Warning" in " ".join(str(c) for c in mock_echo.call_args_list) - def test_race_between_check_and_apply_exits(self) -> None: - # The grace check passes, then the state-only core refuses: defensive - # branch that must exit rather than report a false success. - state = abandonable_state() - with ( - patch(f"{PKG}._echo") as mock_echo, - patch("builtins.input", return_value="YES"), - patch(f"{PKG}.abandon_manual_pick", return_value=False), - pytest.raises(SystemExit) as exc, - ): - cmd_abandon_pick(Config(), state, ["100"]) - assert exc.value.code == 1 - assert "grace period closed" in " ".join( - str(c) for c in mock_echo.call_args_list - ) - class TestAbandonPickLockInteraction: def test_abandon_pick_is_exempt_from_the_lock(self) -> None: @@ -160,20 +150,20 @@ class TestAbandonPickLockInteraction: main() mock_cmd.assert_called_once() - def test_lock_message_advertises_abandon_within_grace(self) -> None: - state = abandonable_state(started_at=IN_GRACE) + def test_lock_message_advertises_abandon(self) -> None: + state = abandonable_state(started_at=RECENT_PICK) with patch("steam_backlog_enforcer.main._shared._echo") as mock_echo: _show_manual_pick_lock_message(state) output = " ".join(str(c) for c in mock_echo.call_args_list) assert "abandon-pick 100" in output - assert "grace day(s) left" in output + assert "day(s) ago" in output - def test_lock_message_hides_abandon_after_grace(self) -> None: - state = abandonable_state(started_at=PAST_GRACE) + def test_lock_message_advertises_abandon_for_old_picks_too(self) -> None: + state = abandonable_state(started_at=OLD_PICK) with patch("steam_backlog_enforcer.main._shared._echo") as mock_echo: _show_manual_pick_lock_message(state) output = " ".join(str(c) for c in mock_echo.call_args_list) - assert "abandon-pick" not in output + assert "abandon-pick 100" in output # ────────────────────────────────────────────────────────────── @@ -228,12 +218,13 @@ class TestAbandonOneOfTwoPicks: cmd_status(Config(), two_pick_state()) output = " ".join(str(c) for c in mock_echo.call_args_list) assert "Manual picks (2)" in output - assert "undoable for" in output + assert "picked" in output + assert "day(s) ago" in output - def test_status_omits_undo_after_grace(self) -> None: + def test_status_shows_age_for_old_picks(self) -> None: state = two_pick_state() - state.manual_picks[0]["started_at"] = PAST_GRACE - state.manual_picks[1]["started_at"] = PAST_GRACE + state.manual_picks[0]["started_at"] = OLD_PICK + state.manual_picks[1]["started_at"] = OLD_PICK with ( patch( "steam_backlog_enforcer.main.status.is_store_blocked", @@ -246,4 +237,6 @@ class TestAbandonOneOfTwoPicks: patch("steam_backlog_enforcer.main.status._echo") as mock_echo, ): cmd_status(Config(), state) - assert "undoable for" not in " ".join(str(c) for c in mock_echo.call_args_list) + assert "picked 8.0 day(s) ago" in " ".join( + str(c) for c in mock_echo.call_args_list + ) diff --git a/steam_backlog_enforcer/tests/test_main_status.py b/steam_backlog_enforcer/tests/test_main_status.py index fe32178..57587e5 100644 --- a/steam_backlog_enforcer/tests/test_main_status.py +++ b/steam_backlog_enforcer/tests/test_main_status.py @@ -5,7 +5,6 @@ from __future__ import annotations from datetime import datetime, timedelta, timezone from unittest.mock import patch -from steam_backlog_enforcer._actions import MANUAL_GRACE_DAYS from steam_backlog_enforcer._total_block import TotalBlockStatus from steam_backlog_enforcer.config import Config, State from steam_backlog_enforcer.main import ( @@ -104,14 +103,11 @@ class TestCmdStatusLockHint: # cmd_abandon_pick (grace period escape hatch) # ────────────────────────────────────────────────────────────── -# Inside the 4-day grace window / already past it. -_IN_GRACE = (datetime.now(timezone.utc) - timedelta(days=1)).isoformat() -_PAST_GRACE = ( - datetime.now(timezone.utc) - timedelta(days=MANUAL_GRACE_DAYS + 1) -).isoformat() +# A pick made yesterday / one made over a week ago. Both are abandonable. +_RECENT_PICK = (datetime.now(timezone.utc) - timedelta(days=1)).isoformat() -def _abandonable_state(app_id: int = 100, started_at: str = _IN_GRACE) -> State: +def _abandonable_state(app_id: int = 100, started_at: str = _RECENT_PICK) -> State: state = locked_state(app_id=app_id, started_at=started_at) state.current_app_id = app_id state.current_game_name = state.manual_pick_game_name diff --git a/steam_backlog_enforcer/tests/test_mcp_actions.py b/steam_backlog_enforcer/tests/test_mcp_actions.py index 4036006..59e6971 100644 --- a/steam_backlog_enforcer/tests/test_mcp_actions.py +++ b/steam_backlog_enforcer/tests/test_mcp_actions.py @@ -79,12 +79,15 @@ class TestAbandonPickGate: assert out["ok"] is False assert "not one of the active manual picks" in out["reason"] - def test_expired_grace(self) -> None: - state = self._state(days_ago=mcp_actions.MANUAL_GRACE_DAYS + 1) - with patch.object(mcp_query.State, "load", return_value=state): - out = mcp_actions.abandon_pick(440) - assert out["ok"] is False - assert "grace period has expired" in out["reason"] + def test_old_pick_is_still_abandonable(self) -> None: + # Past the deleted 4-day grace window, inside the 14-day lock. + state = self._state(days_ago=8) + with ( + patch.object(mcp_query.State, "load", return_value=state), + patch.object(mcp_actions, "abandon_manual_pick", return_value=True), + ): + out = mcp_actions.abandon_pick(440, confirm=True) + assert out["ok"] is True def test_preview_does_not_mutate(self) -> None: with ( @@ -94,7 +97,7 @@ class TestAbandonPickGate: out = mcp_actions.abandon_pick(440) assert out["preview"] is True assert out["game_name"] == "TF2" - assert out["grace_days_left"] > 0 + assert out["age_days"] > 0 amp.assert_not_called() def test_confirm_applies(self) -> None: