mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-11-11 13:47:31 +01:00
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:
parent
0f25fba476
commit
737b49246c
@ -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",
|
||||
}
|
||||
)
|
||||
|
||||
@ -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)
|
||||
],
|
||||
|
||||
@ -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 "
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}")
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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]")
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 == {}
|
||||
|
||||
@ -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
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user