Allow abandoning a manual pick at any time; cap picks at 2

The 4-day grace window left kuhy stuck: three picks locked in, the window
closed on two of them, so nothing could be abandoned and no new pick could
be added.

- abandon-pick now works however old the pick is. MANUAL_GRACE_DAYS,
  manual_pick_grace_remaining() and can_abandon_manual_pick() are deleted;
  abandon_manual_pick()'s existence check is the single remaining guard
  (the previous post-guard and its "race" test became unreachable).
- Config.max_manual_picks 3 -> 2. kuhy's config.json does not set the key,
  so the default change is sufficient.
- status/lock/MCP surfaces report informational age ("picked N day(s) ago",
  age_days) instead of a grace countdown. Note this is a sign flip, not a
  rename: age is now - started, where grace was deadline - now.

Verified live: abandoned Montaro (495890) and Battle Bruise (618510), both
previously refused as EXPIRED; pick-manual 3164500 now reaches its confirm
prompt at 1/2 slots used. 1344 tests, 100.00% coverage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016A1EYiv7e7HGhET84oa9M3
This commit is contained in:
Krzysztof Rudnicki 2026-08-24 15:41:32 +02:00
parent 0f25fba476
commit 737b49246c
15 changed files with 123 additions and 221 deletions

View File

@ -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",
}
)

View File

@ -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)
],

View File

@ -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 "

View File

@ -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

View File

@ -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> 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)",

View File

@ -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

View File

@ -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 <app_id>\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}")

View File

@ -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 <app_id>' while it is"
f"\nstill inside its {_MANUAL_GRACE_DAYS}-day grace window."
f"\nFinish one, or undo one with 'abandon-pick <app_id>' — 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(

View File

@ -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]")

View File

@ -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

View File

@ -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)

View File

@ -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 == {}

View File

@ -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
)

View File

@ -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

View File

@ -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: